diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 7f5b16b9b8..6eac4fbcce 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: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 -README.zh.md: a3369a94c6761e27567b1408d98a81665443f2ef +README.zh.md: 61887320e9e54b35154c79e33e9a9525aceeef50 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index a3369a94c6..61887320e9 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这里存放一类设计文档。**Agent Note(agent 决策记录)** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和契约:Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 +这里存放一类设计文档。**Agent Note** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和契约:Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 ## 布局与命名 @@ -10,7 +10,7 @@ - **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动: - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。 - - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的英文、中文和伴随记录三文件组。 - **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。 diff --git a/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.i18n.yaml new file mode 100644 index 0000000000..88f510a4b0 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.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-29-addressable-queue-operations.md: 258e4e4e50a4c6386a2e8c28402fe32cb8870e1a +2026-07-29-addressable-queue-operations.zh.md: b4370d87892df2e0477c0897b45b4c7a1030978e diff --git a/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.md new file mode 100644 index 0000000000..258e4e4e50 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.md @@ -0,0 +1,44 @@ +# Agent Note: Address pending queue occurrences for edit and removal + +Status: implemented +Archived: 2026-07-31 + +English | [中文](2026-07-29-addressable-queue-operations.zh.md) + +## Problem + +The Web queue rendered pending messages but could not edit or delete one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. + +## Decision + +**Each accepted FIFO occurrence has its own identity.** AgentLoop mints an opaque `InboxItemId` and publishes an `InboxItem` containing that id, the identified `UserMessage`, and its acceptance-time `queued | steering` placement. Reusing one `MessageId` creates distinct inbox identities. Injection bypasses the FIFOs and receives no inbox identity. + +**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history. + +**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. + +**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. + +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. + +## Alternatives considered + +**Address rows by `MessageId`.** Rejected because one immutable message may be sent repeatedly; editing or deleting by message identity would affect an ambiguous occurrence. + +**Apply optimistic browser mutations.** Rejected because driver claim and another client can win before the Host action. Waiting for the authoritative snapshot makes the ownership boundary visible and lets `queue-item-not-found` report a real race. + +**Include pending steering in the queue mutation protocol.** Rejected because QueueDock has no steering interaction, and editing or deleting active-turn input would widen this feature beyond its current consumer. A dedicated steering interaction owns that delivery contract. + +**Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use. + +**Resume a cold Agent for a queue operation.** Rejected because durable session identity does not preserve the process-local inbox capability. Resuming can only produce `not-found` after creating unrelated live state. + +## Verification + +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios capture the default collapsed header before expanding the queue and driving its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. + +## Consequences + +Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface. + +The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.zh.md new file mode 100644 index 0000000000..b4370d8789 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 为待处理队列项提供编辑与移除操作 + +Status: implemented +Archived: 2026-07-31 + +[English](2026-07-29-addressable-queue-operations.md) | 中文 + +## 问题 + +Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 + +## 决策 + +**每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 + +**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。 + +**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 + +**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 + +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 + +## 考虑过的替代方案 + +**通过 `MessageId` 寻址行。** 不予采纳,因为同一条不可变消息可以重复发送;按消息标识编辑或删除会无法确定应操作哪一次入队。 + +**在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 + +**将待处理 steering 纳入队列变更协议。** 不予采纳,因为 QueueDock 没有 steering 交互,而编辑或删除活动轮次输入会把此功能扩展到当前消费方之外。应由专用 steering 交互负责该投递契约。 + +**暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。 + +**为队列操作恢复冷 Agent。** 不予采纳,因为持久会话标识不会保留进程本地的 inbox 寻址凭据。恢复只能在创建无关的实时状态后得到 `not-found`。 + +## 验证 + +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会先捕获默认收起的表头,再展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 + +## 后果 + +queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。 + +现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 58928312af..c5c0ce85a7 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -199,6 +199,9 @@ "feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml": "sha256:f7c6b5db53c32c7475f4f6bb3ae189ed8d67f2163196ab315d6f81fde3aa37d9", "feature/2026-07-28-dsh-meta-source-workspace.md": "sha256:ee8b2f6055b27957fa27258f26a07183b7102933af68c64d0df418e32d3d8754", "feature/2026-07-28-dsh-meta-source-workspace.zh.md": "sha256:0b10db368e04c24be03569a56ec4b69fed66b70c39a3c4f168b5f1c912efcd96", + "feature/2026-07-29-addressable-queue-operations.i18n.yaml": "sha256:0a067b38dd3c02ac41148ed03a7a6fa4c3dee88d5a4b9bad325ff76ebc03c443", + "feature/2026-07-29-addressable-queue-operations.md": "sha256:f4d38d6cc49ad23cda2a287ba7ea5ce0e5fdb0edd41f33e68633f439654ca9fa", + "feature/2026-07-29-addressable-queue-operations.zh.md": "sha256:9aedd7e241cf46ec7a78ae999a7fb105d73da4baf57c9ecf7ae745ee9b98182b", "feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml": "sha256:0865835802348b730542adbe6b7db613750f3786993c6a14dbb2f47686c13c70", "feature/2026-07-29-tui-hidden-mode-assistant-fold.md": "sha256:a5fefebd802e2d9c3c79c7852c1c34c7bbef3f2ac2150d224608b9ec44e966ad", "feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md": "sha256:21bccd1e07ec8dc73b618f428461848bb90b6235afe0b842afb0afab2d5cc575", 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 b8d807098d..66dddfd425 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-11-content-block-vocabulary.md: d926c28e7e197aff28c7b1c09d085febf866832b -2026-06-11-content-block-vocabulary.zh.md: 6361f00abe109bffdb5bd3ff5652df67d6b3c8a1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 +2026-06-11-content-block-vocabulary.zh.md: ffcbbc13dfe9176941f4838b0078d3850403a16c 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 d926c28e7e..5228724bb9 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 @@ -12,7 +12,7 @@ The harness needs one internal language for messages that the loop, session log, Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. -In-session context injection (`context/message`) and mid-turn steering (`steering/message`) originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. +In-session context injection (`context/message`) and mid-turn steering originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. ## Alternatives considered 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 6361f00abe..ffcbbc13df 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(`steering/message`)最初渲染为带标签的 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.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 @@ -25,4 +25,4 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../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。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 -- 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 +- 跨包边界的 ID 使用品牌类型(`CallId`、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 d5b172cb13..49a169445e 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: f1a741927cf63b43b1aaf558efc148ff80d2d881 -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 67439c674f5a77112fc0619c774f7897cf49e835 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 9aadde4f4452a53acce8a3bcf58560ee9722ecfb 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 67439c674f..9aadde4f44 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 @@ -8,7 +8,7 @@ Status: implemented 会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实之间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。 -会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包(package)外部的代码必须能检视历史,但不能保留一个可在之后改写历史的引用;从调用方接受的输入也不能继续连接到调用方拥有的可变对象。 +会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包外部的代码必须能检视历史,但不能保留一个可在之后改写历史的引用;从调用方接受的输入也不能继续连接到调用方拥有的可变对象。 单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。 @@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切分离记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -32,15 +32,15 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要 trace 状态或观察另一个 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` 目前添加需要 trace 状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 -当会话配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 +当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 ## 曾考虑的替代方案 ### 全面的 deep-readonly 类型 -一个被否决的姊妹提案会在公共日志和消息表面上应用递归 `DeepReadonly` 类型,将会话读取路径(`events`、`session/event` 监听器、`deriveMessages()`)翻转为深只读,同时保持进行中的 waterfall 可变。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 +一个被否决的配套提案会在公开的日志与消息接口上应用递归 `DeepReadonly` 类型,将会话读取路径(`events`、`session/event` 监听器、`deriveMessages()`)改为深只读,同时保持进行中的 waterfall(瀑布式事件)可变。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 ### 仅在开发模式冻结 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 6ea6fce11e..d224676be3 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0 -2026-06-11-event-sourced-sessions.zh.md: da3be5965a6900076f253cad065b847c6f5ce17e +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md +2026-06-11-event-sourced-sessions.md: 01f9628c1cfc000aca8654caf5edeff09411fdcc +2026-06-11-event-sourced-sessions.zh.md: ec5c3e766dfa97c5612827023c5cc66bf01a8e6c diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md index 15ba7b23d5..01f9628c1c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -14,7 +14,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end. -Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering. +Ordering contract: the loop claims inbox messages before `agent/pre-step`, opens `step/start` only after an enter decision, then appends the returned `user/message` batch before request derivation. Provider output is assembled and appended as `assistant/message` before tool dispatch, so the durable log records the exact message the tools follow. Regression tests pin that ordering. ## Alternatives considered 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 da3be5965a..ec5c3e766d 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 @@ -14,7 +14,7 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 追加操作是同步的(热路径从不阻塞于 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 -顺序契约:agent loop(智能体循环)*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 +顺序契约:agent loop(智能体循环)先领取 inbox 消息,再运行 `agent/pre-step`;只有 enter 决策才打开 `step/start`,随后在请求派生前追加返回的 `user/message` 批次。提供方输出组装并以 `assistant/message` 追加后才分派工具,因此持久日志记录工具实际遵循的确切消息。回归测试固定了这一顺序。 ## 曾考虑的替代方案 @@ -24,5 +24,5 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 - 回放、追踪与遥测在结构上得到保证,而非事后附加。 - 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 -- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。 +- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。 - 派生成本随日志长度增长,压缩(未来插件)是预期的缓解手段,而非日志变更。 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 a15ebcfddf..3f1b629bc5 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-11-microkernel-event-taxonomy.md: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e -2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +2026-06-11-microkernel-event-taxonomy.md: 202595fed125966a5d77920536e7f4ee88f875fe +2026-06-11-microkernel-event-taxonomy.zh.md: 899c96d86cb7e37d90df349ce5f3f932e0a72f95 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 8bf05b7deb..202595fed1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -12,10 +12,10 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes: -- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/prompt-submit`, `agent/request`, `agent/request-error`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. -- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` and `agent/post-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. +- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. +- **serial** (awaited in listener order) for ordered checkpoints such as `agent/turn-stopping`. - **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint. -- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation. +- **emit** (synchronous fire-and-forget) for notifications: inbox transitions, lifecycle, errors, and the contained immutable `tools/result` observation. Durable session events own turn and step boundaries. The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it. 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 4ff2ab632c..899c96d86c 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 @@ -12,10 +12,10 @@ Status: implemented 纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式: -- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/prompt-submit`、`agent/request`、`agent/request-error`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 -- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 和 `agent/post-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 +- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/pre-step`、`agent/request`、`agent/request-error`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 +- **serial**(按监听器顺序依次 await):用于 `agent/turn-stopping` 等有序检查点。 - **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。 -- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。 +- **emit**(同步 fire-and-forget):用于 inbox 转换、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。轮次与步骤边界由持久会话事件拥有。 事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且自身可替换——外部不得依赖它。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml index ca9d2117ec..c878105c4b 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md 2026-06-11-structured-error-taxonomy.md: 9122193b3d01cf5a4c315e6f7a7218153fd4a60a -2026-06-11-structured-error-taxonomy.zh.md: 56a196ccd10a81b51953887f18e522412cd9463b +2026-06-11-structured-error-taxonomy.zh.md: 71814954bdadae575cdfa63d399f7b5b37da19e4 diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md index 56a196ccd1..71814954bd 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块(name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以那样具有可操作性。非 Error 的 throw 退化更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断。 +故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块(name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型也未能获得本可提供的更具可操作性的反馈。非 Error 的 throw 退化更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断。 ## 决策 在 `dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。 - `LlmError` 和 `ToolArgsError`(dsh-tools)继承该基类,保留各自既有的 code。 -- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。 +- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息保留在日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。 - agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。 ## 后果 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 63063f8d5f..03d64c4d4d 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md 2026-06-13-capability-seams.md: 7c755dced7825d2831acc0901f6412b8e5afe95a -2026-06-13-capability-seams.zh.md: 4148c79cb5e1930dca77eaf3afd2024f508275b5 +2026-06-13-capability-seams.zh.md: 3d0fe2b842766838d247aca75972509bf6c7b951 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 4148c79cb5..3d0fe2b842 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 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包(package)中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 +harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过服务 + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 @@ -15,12 +15,12 @@ harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化 一项可替换的能力由**三个包**构成: 1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.`,仅依赖其词汇依赖(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashProcess`)。 -2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断)。沙箱化/远程后端是实现同一接口的兄弟包。 +2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、spill 文件截断)。沙箱化/远程后端是实现同一接口的兄弟包。 3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash`:`bash` schema,后台句柄注册到通用任务运行时)。消费方 `inject` 接口键,从不导入实现类型。 实现与消费方由此独立演进:沙箱化执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。 -当各部分确实属于同一个关注点时,三分并非强制:LLM(大语言模型) seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现包。不要预防性地拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个包,直到第二种出现。 +当各部分确实属于同一个关注点时,三分并非强制:LLM(大语言模型) seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 接口),适配器作为实现包。不要预防性地拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个包,直到出现第二种实现或第二个消费方。 ## 曾考虑的替代方案 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 dc88be13a2..c897fc3bab 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md 2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 -2026-06-13-twin-llm-adapters.zh.md: d98b57a0a2e7c92453046022e8cb50aa52994c0f +2026-06-13-twin-llm-adapters.zh.md: 391f9259172bc91bb4e5fc036e6064a207a7e308 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 d98b57a0a2..391f925917 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.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」契约中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 ## 决策 @@ -15,7 +15,7 @@ Status: implemented - `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 -二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 +二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生适配器确立了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。这一分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 ## 曾考虑的替代方案 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 5b70e37ebe..c3f4068862 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-14-session-persistence.md -2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda -2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a +2026-06-14-session-persistence.md: 00e129e57c7144fd62eec26f5854ee21dec4e964 +2026-06-14-session-persistence.zh.md: 1c98d5771819e8776d9f5cae4a147d2016ee5978 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 137b2b0112..00e129e57c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -14,23 +14,23 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. Key choices recorded here because they are durable, contested, and surprising: -- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but `load` reconstructs the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. +- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. +- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), and reads use SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, logical interrupted-turn closure, single committed repair, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. 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 2846ee9234..1c98d57718 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 @@ -6,31 +6,31 @@ Status: implemented ## 问题 -会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。 +会话此前仅存在于内存中。示例插件 `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.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 ## 决策 持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +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)是默认物理编码,也可通过配置使用原始行。 -以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的: +以下关键选择记录于此,因为它们长期有效、存在争议且出人意料: -- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但 `load` 会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 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 seam 是更干净的代价。(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.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **规范的持久日志无损保留每个 `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` 在返回可恢复视图前提交这些 closer;合成结果保证恢复后的提供方 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 seam 是更清晰的取舍。(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` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `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.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(冷准备时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 -新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 +新增两个包,以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 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 530e207654..efdb14ea94 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md 2026-06-17-filesystem-capability-seam.md: fee0161e5e8397ac1d1c0e2850efad840c65d971 -2026-06-17-filesystem-capability-seam.zh.md: ee50b36d25315c3d8daed4502bc248977f9e6011 +2026-06-17-filesystem-capability-seam.zh.md: 46e3ad22c530319d0d1b6ba23a8aba8ffc2fdb8c 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 ee50b36d25..46e3ad22c5 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 @@ -16,7 +16,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式的后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。 -我们需要文件系统工具在成为公开包(package)接口之前,以与 bash 相同的能力 seam 形态落地。 +我们需要文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。 ## 决策 @@ -51,7 +51,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` `@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs` 和 `cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有观测状态存储——新鲜度是后端铸造、策略插件记录的版本令牌。 -`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。 +`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent(智能体)或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。 根 `tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read`、`write` 和 `edit`)。它注入 `fs`,从不导入实现包。 @@ -83,13 +83,13 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 +读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方持有的版本失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 -提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 +提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式输出相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 观测状态记录不在 `ctx.fs` 上:成功读取后,执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。 -全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略为未观测 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。 +全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略处理未观测 owner 时采用的分支);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。 字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的陈旧版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;陈旧检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。 @@ -124,11 +124,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` ## 测试 -测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 +测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式输出、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 本仓库曾踩过的防御性模式类别被直接固定: -- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 +- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中独占且仅所有者可访问(`'wx'`、`0o600`)的临时文件暂存,失败时清理,最后原子 rename——与 bash spill 文件规则一致,因为可预测的全局可读临时路径会招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 - **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的陈旧写入可通过另一个路径检测到。 - **并发/陈旧竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 - **HMR(热模块替换)安全与 dispose(资源释放)。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 3b07958faa..853db24cf1 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-18-agent-lifecycle-and-ownership-seams.md: f190b4ba2b7f22d29f473c8a2725401ff371488e -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: dcaa319232baa8951a4f515abc6bce5611da5576 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +2026-06-18-agent-lifecycle-and-ownership-seams.md: 93247a6da7446a5a67db33423d2b766ce4cf3308 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 7c0f27e1e6ddeec91a8031ef7b1d9fd965a4463a diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index f190b4ba2b..93247a6da7 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -47,4 +47,4 @@ The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` bein ## Consequences -This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. The simple synchronous `Agent.send()` ergonomics were preserved; the async lifecycle path is additive, for owners that need it. +This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. Synchronous agent delivery remains simple; the async lifecycle path is additive for owners that need it. diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index dcaa319232..7c0f27e1e6 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -14,17 +14,17 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se ### 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.md)规定了当前的 cause、signal 生命周期与协作式结算契约。 ### 2. `AgentHandle` 异步释放器 -`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个全新会话的释放器,并在断连或插件拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目。在与关闭的竞态中落败的创建流程会 dispose 其尚未发布的 handle。 +`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个记忆化的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个全新会话的释放器,并在断连或插件拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目。在与关闭的竞态中落败的创建流程会 dispose 其尚未发布的 handle。 **拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让会话存储的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。 ### 3. Bash seam 中的所有者令牌 -后台任务所有权从 `tool-bash` 插件本地的 `Map` 移入执行器。`BashExecRequest` 新增可选的 `owner?: string`;解析后的 `BashExecSpec` 将其作为必需但可空的 `owner: string | undefined` 携带(被遗忘的 owner 是可见的 `undefined`,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 `BashExecutor.ownerOf(id): string | undefined` seam 暴露它(不放在公开的 `BashTask` 上——只有一条读取路径,没有冗余 API)。`tool-bash` 完全删除其 `Map`:它在 `start` 时将 `exec.agent?.id`(共享的注册表/会话 id)盖章为 owner,`bash_output`/`bash_kill` 则以 `!== undefined` 语义把 `ctx.bash.ownerOf(id)` 与调用方 token 比较(空字符串 token 仍是真实 owner)。完成通知通过扫描 `ctx.get('agents')?.list()` 查找 `agent.id === ownerToken` 的存活 agent(经 `ctx.get` 读取——`onTaskDone` 运行在 bash fiber 这一外部 fiber 上,直接使用 `ctx.agents` proxy 会抛异常)。由于所有权现在存活在执行器的任务上(随 `dsh-bash` fiber dispose),它能跨越 `tool-bash` HMR 重载,关闭旧的 `XXX(tool-bash-owner-hmr)` 缺口。(`onTaskDone` 监听器仍受 `tool-bash` 的 `apply` effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。) +后台任务所有权从 `tool-bash` 插件本地的 `Map` 移入执行器。`BashExecRequest` 新增可选的 `owner?: string`;解析后的 `BashExecSpec` 将其作为必需但可空的 `owner: string | undefined` 携带(被遗忘的 owner 是可见的 `undefined`,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 `BashExecutor.ownerOf(id): string | undefined` seam 暴露它(不放在公开的 `BashTask` 上——只有一条读取路径,没有冗余 API)。`tool-bash` 完全删除其 `Map`:它在 `start` 时将 `exec.agent?.id`(共享的注册表/会话 id)盖章为 owner,`bash_output`/`bash_kill` 则以 `!== undefined` 语义把 `ctx.bash.ownerOf(id)` 与调用方 token 比较(空字符串 token 仍是真实 owner)。完成通知通过扫描 `ctx.get('agents')?.list()` 查找 `agent.id === ownerToken` 的存活 agent(经 `ctx.get` 读取——`onTaskDone` 运行在 bash fiber 这一外部 fiber 上,直接使用 `ctx.agents` proxy 会抛异常)。由于所有权现在保存在执行器的任务上(随 `dsh-bash` fiber dispose),它能跨越 `tool-bash` HMR 重载,关闭旧的 `XXX(tool-bash-owner-hmr)` 缺口。(`onTaskDone` 监听器仍受 `tool-bash` 的 `apply` effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。) ## 验证 @@ -37,7 +37,7 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se ## 会话所有者令牌在存活 agent 中唯一 -bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入会话和 agent;`SessionStore.enter()` 拒绝重复的存活会话 id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。 +bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布时会依次登记会话和 agent;`SessionStore.enter()` 拒绝重复的存活会话 id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。 ## 曾考虑的替代方案 @@ -47,4 +47,4 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen ## 后果 -本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。同步 `Agent.send()` 的简洁易用性得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。 +本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。同步 agent 交付仍然简单;异步生命周期路径是增量添加的,供需要它的所有者使用。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index 4946fc219a..7c05a0ada8 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-18-session-surface.md: 80034881d0112076759a68737b5931c8ff659d15 -2026-06-18-session-surface.zh.md: 26a3119faf0b6988049a7599ea9551a8ae65d63d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-session-surface.md +2026-06-18-session-surface.md: eeac53534c70099e4102aff9ef226702ea939654 +2026-06-18-session-surface.zh.md: c58d3da049cd6c18e564e596354f5d1831c4756f diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index 80034881d0..eeac53534c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md @@ -27,7 +27,7 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive ``` -1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source. +1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source. 2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface. diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 26a3119faf..c58d3da049 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -27,13 +27,13 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive ``` -1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。 +1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。 2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 ### SurfaceManager:基于增量,而非全量重建 -一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 契约暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置找到两端都包含的端点,并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。 +一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 契约暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置定位两个端点(均包含在范围内),并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。 无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。 @@ -49,9 +49,9 @@ export type SurfaceOp = ### 不变式 -`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:只有 `assistant/message` 可以使用空的溯源列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;溯源必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是可选的不变式服务贡献。 +`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:只有 `assistant/message` 可以使用空的溯源列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;溯源必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选的不变式服务提供的规则。 -每个 surface 可达事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。 +每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。 ## 曾考虑的替代方案 @@ -62,8 +62,8 @@ export type SurfaceOp = ## 后果 -- **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 -- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集分片 seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 +- **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的可进入 surface 的种子事件(见「不变式」一节)。 +- **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。收集分片 seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 - **`packages/session-persistence/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 - **`packages/session-persistence/session-persistence-jsonl`**:无需改动。 - **`packages/session-persistence/session-persistence`**:抽象接口不变。 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 d8dae837fe..7216c38d38 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279 -2026-06-18-shared-persistence-write-coordinator.zh.md: 40a7144038ac0db4ca6cac651c0a3cef5de4afa9 +# 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: 66b73b60ceec9497f1f1226747b8cebd831eb426 +2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850 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 4632351a6f..66b73b60ce 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 @@ -10,9 +10,9 @@ English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) ## Decision -Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`load`/`inspect`) to it. Backend-owned metadata and revision listing bypass the coordinator. +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`) to it. Backend-owned metadata and revision listing bypass the coordinator. -Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including the non-mutating `inspect` contract used by read models. +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle. @@ -23,9 +23,9 @@ The coordinator retires a session from `session/disposed`: it waits for the cont Five required members plus an optional lifecycle hook 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). Resume/load, non-mutating inspection, 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. +- `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). -- `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 `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). +- `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. @@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` leaves interrupted logs and revisions unchanged before `load` performs recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. Coordinator-specific tests cover eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. +The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. ## Alternatives considered @@ -44,4 +44,4 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, and non-mutating inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn cannot race a new live owner by committing interruption closers. New backends implement storage primitives rather than copy the eager write lifecycle. +The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the eager write lifecycle. 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 40a7144038..424ce6ec73 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 @@ -10,11 +10,11 @@ Status: implemented ## 决策 -将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`load`/`inspect`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 +将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括供读模型使用、不修改状态的 `inspect` 契约。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 -协调器为每个确切的存活 `Session` 持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。 +协调器为每个存活的 `Session` 实例持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 @@ -23,9 +23,9 @@ Status: implemented 五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 -- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 -- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 @@ -35,13 +35,13 @@ Status: implemented ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明在 `load` 执行恢复之前,`inspect` 会保持被中断的日志与修订版本不变。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复。协调器专属测试覆盖立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 +共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 ## 曾考虑的替代方案 - **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。 -- **更宽的钩子面**——每个候选钩子都被折叠掉:没有限定存储范围的实时查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径,而 SQLite 已按 id 绑定;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 +- **更宽的钩子面**——每个候选钩子都被折叠掉:没有限定存储范围的存活会话查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径,而 SQLite 已按 id 绑定;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 ## 后果 -协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.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 04f7dcb1a3..0af0c0038b 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md 2026-06-20-branded-ids.md: 7c0b7ca89418e8312ec728223dac519f70edc3ed -2026-06-20-branded-ids.zh.md: 8b41ad3c3c85690fb03b20a208f8460a1614477b +2026-06-20-branded-ids.zh.md: 039953754b184af97eb9b4ea3bbb1325d45eda25 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 8b41ad3c3c..039953754b 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 处理;该机制由纯类型包(package) `@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 }` 机制,为 `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 今天仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 -**缺口 2:*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP 的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 +**缺口 2:*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 ## 决策 @@ -65,5 +65,5 @@ export function OwnerToken(id: string): OwnerToken { ## 后果 - **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* 会话 id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 Agent Note 倾向于面向模型或用于访问控制的 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 341ea482c0..e1171d5877 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: cb9d9487cd274696ae20dddab8c6888b4cf4b833 -2026-06-20-generic-long-running-tool-runtime.zh.md: 77538105ba2841479238045327de493c5a835b7f +2026-06-20-generic-long-running-tool-runtime.zh.md: fe5ca223975445991e7fe96376cbfccf432ad241 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 77538105ba..fe5ca22397 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 @@ -91,7 +91,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `BashProcess`,提供增量读取、取消、退出事实以及不拒绝的完全停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留实时句柄。前台调用方继续直接使用 `resolve` 和 `run`。 -对于后台 bash,`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消,将 `done` 映射为 completed 或 killed 的 `TaskOutcome`,并将 `readOutput()` 映射为进程的有界增量输出,以及溢出文件与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。 +对于后台 bash,`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消,将 `done` 映射为 completed 或 killed 的 `TaskOutcome`,并将 `readOutput()` 映射为进程的有界增量输出,以及 spill 与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。 对于后台 subagent,`dsh-tool-subagent` 创建由任务拥有的 `AbortController`,并在任务 starter 内启动提供方。无论提供方发布前后,取消都会中止同一个 signal。`done` 同时等待子运行结果和子运行释放,将已完成输出映射为最终结果,将中止映射为 `killed`,并将其他停止原因或基础设施失败映射为 `failed`。中间子历史保留在子会话中,不通过 `readOutput()` 暴露。 @@ -117,7 +117,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ### 由运行时拥有输出接收端 -推送式接收端可以集中缓冲,但 bash 已经在执行器 seam 后拥有有界缓冲、截断与溢出文件。拉取格式化增量能够保留这一所有权。拥有存储的持久化后端可能足以支持重新审视生产方接口。 +推送式接收端可以集中缓冲,但 bash 已经在执行器 seam 后拥有有界缓冲、截断与 spill 文件。拉取格式化增量能够保留这一所有权。拥有存储的持久化后端可能足以支持重新审视生产方接口。 ### 随机 id、提升或生命周期会话事件 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 bf6f030683..dc841c67bd 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: 24725dcf300cf69e9cc72580d0c8afe937d4e2b9 -2026-06-21-bounded-llm-request-recovery.zh.md: 5f03a65b00be8d3349addce82e4f3faa2af1fe7e +2026-06-21-bounded-llm-request-recovery.md: 587f2d26eee922e91c8797018964f983890eb8ec +2026-06-21-bounded-llm-request-recovery.zh.md: be52ece63ce794cb13cdf657b73bae6e9f42cf6d 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 24725dcf30..587f2d26ee 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -4,11 +4,11 @@ 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. +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. ## Problem -`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. 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. +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. @@ -40,9 +40,9 @@ interface LlmFailure { `code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events. -`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. An adapter-thrown `Error` keeps its exact object identity: the final-adapter scope associates the normalized facts with that object in call-local sidecar state and rethrows it unchanged; a non-`Error` throw is wrapped as today. `llmFailureOf(stream, error)` retrieves those facts alongside the existing provenance check, while an in-band finish without an error object becomes a new `LlmError`. This preserves listeners that key on error type or identity while giving all final-adapter failures, including unknown SDK exceptions, an `UNKNOWN` terminal payload. +`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an `UNKNOWN` payload. Exact thrown-object identity does not cross the LLM stream seam. -The agent loop keeps `RequestError` as that exact error object and passes `LlmFailure` as a separate argument to `agent/request-error`; it does not mutate possibly frozen third-party errors. It also uses the payload when converting an in-band finish and when recording an unrecovered `turn/end.reason`. +The agent loop passes the terminal finish's `LlmFailure` to `agent/request-error` and uses the same payload when recording an unrecovered `turn/end.reason`. Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. @@ -106,8 +106,8 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Verification -- `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available. -- An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. +- `LlmFailure` is the single serializable payload for adapter throws, error finishes, and aborted finishes; normalization preserves stable code, status, retry delay, branded provider request id, and caller-abort versus adapter-timeout classification where available. +- Adapter throws become terminal failure chunks before reaching consumers; middleware and consumer exceptions remain thrown outside model-request recovery. - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. - Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. - `agent/request-error` carries current failure facts, immutable prior-retried failure facts, and the serving registration's immutable retry policy; a success clears the history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. 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 5f03a65b00..be52ece63c 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 @@ -1,14 +1,14 @@ -# Agent Note: LLM(大语言模型)暂时性请求失败的有界恢复 +# Agent Note: LLM 暂时性请求失败的有界恢复 Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 -[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM 流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和 stream sidecar 的机制。 ## 问题 -`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-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.md)规定这一返回契约。 该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 @@ -40,9 +40,9 @@ interface LlmFailure { `code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。 -`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。 +`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。最终适配器边界会从适配器抛出值中分离这些事实,并发出相应的终止 finish;未知 SDK 异常会获得 `UNKNOWN` 载荷。精确的抛出对象身份不会跨越 LLM stream seam。 -agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。 +agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agent/request-error`,并在记录未恢复的 `turn/end.reason` 时使用同一载荷。 适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 @@ -58,11 +58,11 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 -插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall(瀑布式事件)工作与退避。effect 清理会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件释放后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall 工作与退避。effect 的 dispose(资源释放)会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件 dispose 后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)的 dispose 达到完全停稳。 -休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并,并通过其浏览器安全的 `./types` 子路径导出载荷;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它会与生产渲染器及回放/快照覆盖一起交付。 +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析后策略 key、提供方策略重试编号、特定于 mode 的有限上限(如有)、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并,并通过其浏览器安全的 `./types` 子路径导出载荷;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它会与生产渲染器及回放/快照覆盖一起交付。 -对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件 dispose 会结束等待且不返回重试动作,此后仍以循环的取消/dispose 检查为准。 agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)、ACP(Agent Client Protocol)和 headless 示例组合使用同一套按提供方路由的策略。随产品交付的 Web 组合也会加载该插件,因此浏览器请求与命令行请求使用相同的提供方默认值。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 @@ -70,7 +70,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。 -`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 +`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩(compaction)摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 ### 在能够终止停滞流的位置施加边界 @@ -106,13 +106,13 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 验证 -- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。 -- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 +- `LlmFailure` 是适配器抛出、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id,以及调用方中止与适配器超时之间的分类。 +- 适配器抛出值会在抵达消费方前成为终止失败 chunk;middleware 与消费方异常仍在模型请求恢复之外抛出。 - DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 -- Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 +- pi-ai 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的线路请求尝试;独立测试确保移除任一边界都会失败。 - `agent/request-error` 携带当前失败事实、不可变的先前已重试失败事实,以及实际服务注册所对应的不可变重试策略;成功会清除历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 - 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。 -- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 +- 退避期间执行 HMR 的测试证明:dispose 过程会注销监听器、中止并等待其捕获的回调,dispose 后不发出重试决策,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 @@ -135,4 +135,4 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - [可重建请求](../../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)负责当前已关闭步骤的请求恢复 seam 与有界溢出重试。 -- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [提供方路由的 LLM(大语言模型)适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 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 e72b100327..56c9e53319 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: 5529a42dddf4615ee1054b4d1dee36b077800d7d +2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8 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 5529a42ddd..ac4affce58 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 @@ -20,18 +20,18 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - **`From` 是标准的,但不适合作为强制默认值。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理应当发送它以便服务器联系运营者,但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。 - **请求体中的 `user` 或 `metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特有的 body schema,要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。 - **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试其客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 作为应用。 -- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 +- **pi-ai 有原生支持的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的线路契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 ## 决策 -在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 +在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 本 Agent Note **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 Agent Note 定义的共享 `User-Agent` 归属。 提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) -- 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量 +- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 - 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -51,7 +51,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 已落地的契约: -- `dsh-llm` 为 `LlmAdapter` 作者文档化了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。 +- `dsh-llm` 为 `LlmAdapter` 作者文档化了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约(adapter contract)章节)。 - 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 - `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 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 2175305799..fc370429b6 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md 2026-06-24-web-capability-seam.md: b705236690859961ed69b307dbb59ebefcbd65ac -2026-06-24-web-capability-seam.zh.md: 9b6899c922524350d2eee62140480fd76a450baa +2026-06-24-web-capability-seam.zh.md: e3dc836004bf785c6811e4c4014e105266dbaade 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 9b6899c922..e3dc836004 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 @@ -280,7 +280,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 测试 -每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 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 614868ada0..3463e0d6b1 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 4700222aa2e0f91d9f355495c228e2eb92825f55 -2026-06-26-file-context-as-event-gate.zh.md: 21c8706bcc14790a5092fa59e03bce329049760a +2026-06-26-file-context-as-event-gate.zh.md: e0a3619859c2ab4eb99f7cbf69375f9fe40d10b7 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 21c8706bcc..e0a3619859 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(agent 决策记录)](../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.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 690aa1f9b4..5dbfcf94ef 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md 2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 325e3d303bdb6836c8928fdae00de59fb954ff37 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9d67797f86..325e3d303b 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策负责管理 `DSH_*`:这类环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index c6d8176b90..2317a5a14e 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-30-event-domain-semantics.md: 75c1cac11d1bfc9aa7fba9c523eab8c0475027e8 -2026-06-30-event-domain-semantics.zh.md: a412b8735b72274252473f3218e0d57d4f814bde +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md +2026-06-30-event-domain-semantics.md: 14102b105e7bcaa6a00ac9c406933f772fc45a6f +2026-06-30-event-domain-semantics.zh.md: 91e490b57bcdc1ed95f0b8b40cc9e16ef4d36f29 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md index 75c1cac11d..14102b105e 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ **Three domains, one job each, with a single boundary rule.** - **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path. -- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`). +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (a `user/message`). - **`tools/*` — the tool registry + execution seam.** **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. @@ -33,7 +33,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ - The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. `Session.append` owns post-commit observer containment, so a throwing boundary observer cannot change the turn outcome or starve later consumers; an acceptance or internal validation failure still escapes before the boundary enters the log. - Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. - The loop marks the step open (`stepOpen = true`) only after `append('step/start')` returns. Internal dispatch validation runs before the log push and may reject without opening a step; post-commit `session/event` observer failures are contained inside `Session.append`. The marker therefore represents exactly the committed boundary that owes a later `step/end`. -- The full realization of this is [the simplification Agent Note "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that Agent Note's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`. +- The full realization of this is [the simplification Agent Note "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that Agent Note's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable mid-turn steering `user/message`. - The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events. 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 a412b8735b..91e490b57b 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 @@ -21,19 +21,19 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **三个域,各司其职,以一条边界规则统一。** - **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。 -- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 +- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入/领取/丢弃与错误。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(以 `user/message` 呈现)同理。 - **`tools/*`——工具注册表与执行 seam。** **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接将其进行中的提示词与精确对应的 `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.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),行为与其测试一同迁移(或一同消亡)。 +- 循环不再 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/message`。 +- 完整实现见[简化 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`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml index e48cfbacb6..da7848d581 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md 2026-07-02-fs-per-session-cwd.md: d3f54e89e735016a373fa14c60123c681b3e7adf -2026-07-02-fs-per-session-cwd.zh.md: ae732a3e4dacc3d4b800044aad60df3f3ce17cc0 +2026-07-02-fs-per-session-cwd.zh.md: 86058437a14f64cc7faf6e6ad413c52efba5fd39 diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md index ae732a3e4d..86058437a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -26,7 +26,7 @@ ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区 ### 为何由调用方(而非提供方)提供 cwd -提供方 seam 不得依赖 `dsh-agent`/`dsh-session`——它是一个文本存储后端,沙箱或远程实现同样满足该接口,而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution`(`exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包(package)边界处显式优于隐式」的约定:基准目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 +提供方 seam 不得依赖 `dsh-agent`/`dsh-session`——它是一个文本存储后端,沙箱或远程实现同样满足该接口,而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution`(`exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基准目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 默认值只存在于一个地方——提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。 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 6d8498e21c..48d3522a70 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: d82141f519bff66df000f1316093aacd38b8e42b -2026-07-02-tool-render-intent-union.zh.md: 71fe81ba8f87cb707512cf7880126c006321c799 +2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23 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 71fe81ba8f..e145908e01 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 映射已被 [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.md)取代。 ## 问题 @@ -36,7 +36,7 @@ interface GenericResultView { card: 'generic'; title?: string; content?: Content interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string } ``` -`card` 在每个变体上都是**必填**的——真正的判别式,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个由插件添加但被 bridge 静默丢弃的变体,比编译错误更糟糕。新增变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。 +`card` 在每个变体上都是**必填**的——真正的判别字段,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个由插件添加但被 bridge 静默丢弃的变体,比编译错误更糟糕。新增变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。 ### 为什么带标签联合类型优于字段集合 @@ -47,7 +47,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ### 生产者映射 - `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。 -- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` → `generic`。通用 `task_*` 控制工具拥有各自的 generic 卡片。 +- `dsh-tool-bash` 前台运行 → `terminal` 调用 + `terminal` 结果;`run_in_background` → `generic`。通用 `task_*` 控制工具拥有各自的 generic 卡片。 - `dsh-tool-todo` → `generic`。 ### 终端回退的归属 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 1f232965a5..8a2190b500 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: f1379e143a94a3ae3a07b3120c6f0b9fc8561fe9 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d 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 f1379e143a..341b3a89f4 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 @@ -18,7 +18,7 @@ Status: implemented ## 决策 -**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包(package)的提示词 section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。 +**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 包的提示词 section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。 ### 组装上下文 @@ -26,13 +26,13 @@ Status: implemented ### 提示词变量 -插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 `dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`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`。 ### 工具指导归属 @@ -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.md)。 ## 曾考虑的替代方案 @@ -49,7 +49,7 @@ Status: implemented - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据提供方名称选择措辞**:`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.md)中被否决。 ## 不在范围内 @@ -68,5 +68,5 @@ Status: implemented - 组装后的提示词中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 - `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 - 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 -- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。 +- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望明确暴露的撰写错误。 - 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。 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 f9c309c39f..d326b413bb 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0 -2026-07-05-reconstructable-requests.zh.md: caf51c3065e416fd11aebc1c1d4dc2ee248e2e5c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e +2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d 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 153d37a2fa..2f559a3052 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,11 +22,11 @@ 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, tool schemas, and session prefix, 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. `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. -Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance before the generic `agent/pre-step` checkpoint and boundary snapshot. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. +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. -**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. +**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. **Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. @@ -47,9 +47,9 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## Consequences - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. -- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). +- Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction. - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` 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. -- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. +- `agent/pre-step` is the current-request message seam; direct inbox mutation is the eventual later-request seam. - Tool-result trimming (planned) 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. 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 caf51c3065..26abdc024a 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 @@ -1,4 +1,4 @@ -# Agent Note: 每个 LLM(大语言模型)请求都可从会话日志重建 +# Agent Note: 每个 LLM 请求都可从会话日志重建 Status: implemented @@ -22,11 +22,11 @@ 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,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个步骤重建提示词组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 +每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 -**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 +**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 **强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 @@ -47,10 +47,10 @@ Status: implemented ## 后果 - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 -- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 +- 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。 - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 -- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 +- `agent/pre-step` 是当前请求的消息 seam;直接修改 inbox 则是最终进入后续请求的 seam。 - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(reasoning 选项、额外 body 参数)应归属何处。 +- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。 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 516dd4edcb..efeb9d72f3 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: afd45027e8b56cbf1d17e6dec749d8602c81124d -2026-07-05-subagent-provider-lifecycle-events.zh.md: 58d439936a3f2cc51d8190cbebe8e68cdb14c855 +2026-07-05-subagent-provider-lifecycle-events.zh.md: 01e12946c74fc4fb33c96e047d2030e99b692f47 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 58d439936a..01e12946c7 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,7 +6,7 @@ Status: implemented ## 问题 -[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[提示词变量 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 上到达,时机不确定。 如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 @@ -31,6 +31,6 @@ Status: implemented ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 -- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持提示词组装的时效性。 -- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录到日志中,不会阻止后续镜像运行或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费方映射](../../../../docs/event-producer-consumer.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 4322906bb9..46aebedb0a 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 38c4adc7a4f85d45e53e70fcac84073ab4e50775 -2026-07-05-windows-jsonl-durable-publish.zh.md: 8dc77a0ab1b9273cf3f6ecb26916c7861ee81ec4 +2026-07-05-windows-jsonl-durable-publish.zh.md: 3a91b7dab0751ad1b7ac1a425371ccf62d23e89c 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 8dc77a0ab1..3a91b7dab0 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 @@ -16,7 +16,7 @@ JSONL 后端会在 `materialize()` 内部、任何命名空间变更之前分流 POSIX 保留现有协议:创建根目录、项目目录与会话目录,并对其父目录执行 fsync;写入临时文件并对其执行 fsync;使用 `link()` 发布,确保绝不覆盖已有的最终日志;对会话目录执行 fsync;最后移除多余的临时硬链接。 -Windows 通过持久的暂存发布来创建缺失目录:在固定的 `.dsh-mkdir-` 前缀下创建一个随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包(package)会分发原生 loader 和预构建的平台模块。 +Windows 通过持久的暂存发布来创建缺失目录:创建一个以固定的 `.dsh-mkdir-` 为前缀的随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包会分发原生 loader 和预构建的平台模块。 ## 考虑过的替代方案 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 471cf9f92d..c43bfd8960 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md 2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de -2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de +2026-07-06-timeout-deadline-library.zh.md: 16f1a7eba902e6d7b3e2f730cb5df702a37bd429 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 c3d3cdf1c6..16f1a7eba9 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 @@ -92,7 +92,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 - **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 -- **LLM 适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 +- **LLM(大语言模型)适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 ## 后果 @@ -102,14 +102,14 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器契约,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema/快照覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在实现后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 **统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 -**每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得一个小型共享原语严格更优,因此成本/收益不同。 +**每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得采用一个小型共享原语明显更简洁,因此成本/收益不同。 **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 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 6663606c3c..17381d7269 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 5e42660360e5a23b419c75b9c8006bec459bc322 -2026-07-06-tool-result-retention-library.zh.md: 6e824667f17b361efb57b173c44f489da2cab3b3 +2026-07-06-tool-result-retention-library.zh.md: 422c301ba5f1aab33a54e1deffe42f390bcb662d 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 6e824667f1..422c301ba5 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 @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包(package)需要限制输出时直接导入它。 +`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包需要限制输出时直接导入它。 该库包含两个相互独立的 retainer: @@ -152,6 +152,6 @@ const formatGrepNotice = (notice: RetentionNotice): string => **把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`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.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 bd2426f377..f51277307f 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 69fd1ee721de69621d3b57c10d960da0651b94dd -2026-07-07-tool-call-timeout-policy.zh.md: c0dc56127cb983bd515db424d0ca37da9d0e978a +2026-07-07-tool-call-timeout-policy.zh.md: 0b8d07788dce152985e133923bb673f80175e68d 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 c0dc56127c..0b8d07788d 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 @@ -79,7 +79,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { `web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 -`dsh-web-fetch-local` 保留一个配置级别的 `timeoutMs` 作为大型资源兜底,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。 +`dsh-web-fetch-local` 保留一个在提供方层面配置的 `timeoutMs`,作为较大的资源兜底值,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。 `bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。 @@ -89,7 +89,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { ## 曾考虑的替代方案 -**将插件命名为 `tool-timeout`。** 字面的 Agent Note 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 +**将插件命名为 `tool-timeout`。** 字面的 Agent Note 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 **仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。 @@ -101,14 +101,14 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { **让 `timeout-policy` 自行匹配工具参数。** 诸如「当 `bash.run_in_background` 为 true 时禁用超时」之类的规则引擎会让策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来规避此问题。 -**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 +**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose(资源释放)路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 **使用 `Promise.race` 对非协作工具强制超时。** 与超时库 Agent Note 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 ## 后果 -- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。 +- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发接口。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到未经处理的工具异常。 - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 -- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这一未达完全停稳的工具体,而不是竞速它;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 +- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这个尚未完全停稳的工具体结束,而不是与它竞速;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 - 与字面提案的偏差,按 implemented-Agent Note 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 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 c241ef4ece..2145468f1f 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 7c0ca90452645d251559be25108d12883210d00e -2026-07-08-tool-output-spill-files.zh.md: 917d710eb8650e2797287578edd1b0d62813bbd3 +2026-07-08-tool-output-spill-files.zh.md: a1bde1131374cd11215006b822dfc19aea707747 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 917d710eb8..a1bde11313 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,15 +8,15 @@ Status: implemented 工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 -这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行临时实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 -其形态与超时策略设计一致:工具作者声明规范值与 Native renderer,由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 ## 决策 在新的 `packages/spill/` 分组下增加一层轻量落盘存储 seam 和一个默认落盘策略插件: -| 包(package) | 角色 | +| 包 | 角色 | |---|---| | `@deepseek-ai/dsh-spill` | 接口:`ctx.spillStore`、词汇类型,不包含存储实现。 | | `@deepseek-ai/dsh-spill-local` | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 | @@ -140,7 +140,7 @@ ctx.tools.register(defineTool({ 最终结果策略不能取代由工具负责的提前落盘。部分有用内容并不存在于最终 `ToolExecutionResult.content` 中: - `bash` 的最终输出已经是尾部内容加临时落盘路径;完整的 stdout/stderr 流位于执行器文件中。 -- `subagent` 的最终输出是子 agent(智能体)的最终回答,而不是子 agent 的执行轨迹。 +- `subagent` 的最终输出是 subagent 的最终回答,而不是 subagent 的执行轨迹。 - 未来的工具可能生成从未出现在最终 `ToolExecutionResult.content` 中的运行时产物。 这些场景可以在后续工作中直接使用 `ctx.spillStore`,不属于首个示例的范围。 @@ -174,7 +174,7 @@ ctx.tools.register(defineTool({ 默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。 -本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 +本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。 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 1904a25158..cefb0b0dd2 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-after-call-compaction-pressure-and-overflow-recovery.md -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 51d488db28c57426c75c9ed1cfc90892261c0224 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ae33cf5c2e944e584cd3d3c6ff76d93619adf7dc +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 6fbd5e2c9d57da3f25c72c652ca50eb45b84323c +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: c46b3eeeb446165192ef44de938c9a5a657a02f8 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 51d488db28..6fbd5e2c9d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -12,17 +12,17 @@ Successful calls are not the only pressure signal. A provider can reject a reque ## Decision -### Successful pressure moves to a durable post-step checkpoint +### Successful pressure runs at the next pre-step boundary -`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields. +`agent/pre-step` receives the exclusive claimed message batch plus `{ turn, step, signal }` and returns the final reject/enter decision. It carries no compaction-only prompt or prefix fields. -The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A propagated listener failure is an ordinary turn failure; it never enters model-request recovery. Compact-basic contains its expected operational failures as described below. +Compact-basic wraps `agent/pre-step` before each proposed request. At a continuation boundary the preceding assistant output, every dispatched or synthetic tool result, post-tool context, and steering are already durable, so pressure policy sees the complete successful-call state without splitting an assistant tool call from its result. At the initial boundary a headerless session has no completed routed request and produces no pressure work. Compact-basic contains operational failures, warns, and delegates without rejecting the proposed step. `dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue from the latest durable surface: full history before any replacement, or the pruned surface if pruning already landed. ### Request recovery is limited to the final model boundary -`RequestError` and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. +`agent/request-error` represents terminal failures from the final adapter boundary. Adapter selection, dispatch, iterator construction, and iteration throws become terminal `error` or `aborted` finishes before the agent loop consumes them; adapter-emitted terminal finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) owns this normalization boundary. The failed step closes before recovery runs. A handling listener repairs durable state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The loop then closes the failed turn and opens one retry turn from the durable log without an intervening idle notification. Retry policy and attempt counts remain plugin-owned; compact-basic clears its per-agent overflow count when the chain reaches terminal `agent/settled`. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns the return boundary. @@ -42,11 +42,11 @@ The default summarizer resolves explicit configuration, then the latest logged r ## Testing -Unit tests cover final-adapter failure provenance and identity, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. +Unit tests cover the final-adapter normalization boundary, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. ## Alternatives considered -- **Keep provisional pre-step pressure and add more arguments** — rejected because later routing and request mutation remain outside any earlier snapshot, while generic lifecycle becomes coupled to one plugin. +- **Add compaction-only fields to pre-step** — rejected because the canonical durable session and token meter already own the measurement input; the generic lifecycle need not carry a second envelope. - **Retry the same numbered step** — rejected because recovery appends durable events after the failed boundary. A new step preserves balanced nesting and reconstructability. - **Retry whenever `compactIfNeeded` returns a result** — rejected because a custom backend can report success without changing model-visible state. `replaceGeneration` is the authoritative proof. - **Let compact-basic parse provider wording** — rejected because classification belongs at adapters and must cover both thrown and in-band delivery. @@ -54,8 +54,8 @@ Unit tests cover final-adapter failure provenance and identity, closed-turn retr ## Consequences -Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. +The next pre-step pressure check describes the preceding completed routed request, including durable tool results and newly claimed input. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. -The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk. +The cost is pressure work in the shared pre-step waterfall and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk. -This Agent Note supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. +The [claimed pre-step lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) supersedes this note's former post-step trigger. The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. 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 ae33cf5c2e..c46b3eeeb4 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 @@ -1,4 +1,4 @@ -# Agent Note:调用后压缩压力与上下文溢出恢复 +# Agent Note: 调用后压缩压力与上下文溢出恢复 Status: implemented @@ -6,23 +6,23 @@ Status: implemented ## 问题 -`agent/pre-step` 运行在最终请求路由之前,也早于 assistant 输出、工具结果、缓冲上下文与 steering 的产生。即使它接收已装配提示词与会话前缀,压力视图仍是临时的,因为 `agent/request` 还可以改变路由或调用配置,工具 schema 也没有与这些输入一同冻结。增加字段无法让调用前状态描述已完成调用,还会把通用 seam 与压缩耦合。 +`agent/pre-step` 运行在最终请求路由之前,也早于 assistant 输出、工具结果、缓冲上下文与 steering(中途引导)的产生。即使它接收已装配提示词与会话前缀,压力视图仍是临时的,因为 `agent/request` 还可以改变路由或调用配置,工具 schema 也没有与这些输入一同冻结。增加字段无法让调用前状态描述已完成调用,还会把通用 seam 与压缩耦合。 成功调用也不是唯一的压力信号。提供方可能在返回 usage 之前就因上下文窗口超限拒绝请求,一些成功调用也不提供 usage。因此,系统需要可回放的调用后压力,以及一条狭窄的失败恢复路径;当压缩无法证明取得有效进展时,必须保留原始提供方错误。 ## 决策 -### 成功压力移动到持久 post-step 检查点 +### 成功压力在下一个 pre-step 边界运行 -`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。 +`agent/pre-step` 接收独占的已领取消息批次与 `{ turn, step, signal }`,并返回最终 reject/enter 决策。它不携带压缩专用的提示词或前缀字段。 -循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。向外传播的监听器失败属于普通 turn 失败,绝不会进入模型请求恢复;compact-basic 会按下文所述在内部处理其预期的操作性失败。 +Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步边界,前一条 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都已经持久化,因此压力策略能看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。初始边界上的无 header 会话尚无已完成路由请求,因此不执行压力工作。Compact-basic 会在内部处理操作性失败、发出警告并继续委托,不会 reject 拟议步骤。 -`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。 +`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在已完成的路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。 ### 请求恢复只覆盖最终模型边界 -`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。 +`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop 消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM 流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。 恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 @@ -32,7 +32,7 @@ Status: implemented `CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 -对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 +对于 `pressure`,compact-basic 先解析持久提供方/模型目标对应适配器所维护的容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。未达到压力阈值时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力已降至安全水平则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ kind: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 @@ -42,11 +42,11 @@ Status: implemented ## 测试 -单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 +单元测试覆盖最终适配器规范化边界、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 ## 考虑过的替代方案 -- **保留临时 pre-step 压力并增加更多参数**——不予采纳,因为后续路由与请求变换仍在更早快照之外,同时通用生命周期会耦合到单个插件。 +- **向 pre-step 增加压缩专用字段**——不予采纳,因为规范持久会话与 token meter 已拥有计量输入;通用生命周期不需要携带第二份信封。 - **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。 - **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。 - **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。 @@ -54,8 +54,8 @@ Status: implemented ## 后果 -Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 +下一个 pre-step 的压力检查描述前一个已完成的路由请求,包括持久工具结果与新领取输入。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 -代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 +代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -本 Agent Note 只取代[压缩能力接缝 Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.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 27e43ef4b2..7a0b74dfaf 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: f749d6a72b4c32a189a9f848595076457819d9b9 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: f3da981c478ef08672a82f23ab9cd42e0f38ebab +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 5a8e15aab79875bdb08e6347199924c4215a2705 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 f749d6a72b..f3da981c47 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 @@ -27,7 +27,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t 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: -- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` 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 the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` 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-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` 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). 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. 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 2b511573bc..5a8e15aab7 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 @@ -21,28 +21,28 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。 -术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放预期输出、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 +术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP 回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两包 +### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两个包 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: -- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并刷新 `shutdown` 响应后 dispose 根运行时以排空持久化,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 - [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 -配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。 +配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 -### 插件解析:VFS 装载真实包树,闭包清单就是部署根目录 +### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 [`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/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。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 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 @@ -50,11 +50,11 @@ Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 -exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 +exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-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` / `deepseek-harness-runtime-bin`。 ## 工作线程插件 @@ -62,24 +62,24 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 -手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 +手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 ## 曾考虑的替代方案 -**裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与“配置决定一切”相悖。最终路线实际是“官方 SEA 基础 + pkg 的 VFS/模块钩子层”;否决的是裸用方式,而不是 SEA 本身。 +**裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与「配置决定一切」相悖。最终路线实际是「官方 SEA 基础 + pkg 的 VFS/模块钩子层」;否决的是裸用方式,而不是 SEA 本身。 **pkg 标准模式。** PoC 证明该模式不可行,而非权衡后放弃:它通过 esbuild 将 ESM 转为 CJS + V8 字节码,但运行时 VM 编译没有接入动态 `import()` 回调,任何 `import()` 都会抛出 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`,`--options experimental-require-module` 也无效;此外,它依赖社区补丁版 Node 二进制(macos-arm64 没有预编译版本,现场从源码编译约需 10 分钟)。该模式不适用于本仓库架构。 **每包 ESM→CJS 预打包进 VFS。** 保持真实解析语义、只降级模块格式的折中;`--sea` 直接通过实测,这层构建复杂度无需引入。 -**让 jsonrpc-agent 承担完整闭包依赖。** 应用入口将声明 53 个以上自身并不 `import()` 的依赖,使“打包清单”伪装成真实依赖关系,还会迫使 `constraints` 为其增加 `cordis-in-dependencies` 与 `files` 通配符两个例外。将闭包清单放在 Python 侧的清单包后,`constraints` 不需要任何例外,`bin` 也能保持与 acp-agent 同构的正常包形状。 +**让 jsonrpc-agent 承担完整闭包依赖。** 应用入口将声明 53 个以上自身并不 `import()` 的依赖,使「打包 manifest」伪装成真实依赖关系,还会迫使 `constraints` 为其增加 `cordis-in-dependencies` 与 `files` 通配符两个例外。将闭包清单放在 Python 侧的 manifest 包后,`constraints` 不需要任何例外,`bin` 也能保持与 acp-agent 同构的正常包形状。 **开放插件集(从磁盘加载用户插件)。** 本期采用封闭集;PoC 同时证实,可以通过 `ctx.baseUrl` 相对路径通道从 VFS 外的磁盘 `import()` ESM。该能力列为后续演进,届时还需解决外部插件与 exe 内 Cordis 实例的共享问题。 ## 后果 -**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖清单这两个事实源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 +**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 **付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级需要显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 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 d05f034c0f..dbdc4347f6 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: d6b865977a76061784c88dbad089fa5963be8c7e -2026-07-12-agent-scope-runtime-design.zh.md: 5b6b9b1ca582d83a267a30f8f76e38ea87d52e9b +2026-07-12-agent-scope-runtime-design.md: b93c291957fab98ab9d0d04eb816bb01a15a0b51 +2026-07-12-agent-scope-runtime-design.zh.md: 8b1332c23ce3b6c0332fcde40b4f4ac8dd20aa45 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index d6b865977a..b93c291957 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -150,7 +150,7 @@ sequenceDiagram Every teardown request joins one memoized path. The order is: 1. Deactivate creation or driving and let synchronous publication finish. -2. Stop and drain the driver, including idle injection flushes. +2. Stop and drain the driver, discarding any injection that remains pending. 3. Detach the agent. 4. Detach the session. 5. Dispose the agent scope. @@ -238,7 +238,7 @@ For a native call, the observer deletes the stage and commits its value only whe For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. -Once a value is pending or committed, a scoped monotonic guard denies later tool calls. After commit, the ordinary serial `agent/turn-stop` listener returns a stop decision after continuation and steering have already folded. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. +Once a value is pending or committed, a scoped monotonic guard denies later tool calls. The successful structured-output execution calls `exec.concludeTurn()`, so its own immutable result carries `concludesTurn: true` and the loop ends the tool loop at that step. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. @@ -250,9 +250,9 @@ Prompt assembly is intentionally cooperative, but three execution facts need one |---|---|---| | Tool pre-policy | Deny monotonically | A later listener must not re-allow an already denied call | | Tool result | Observe the immutable committed outcome | Structured output must commit only the result that actually escaped the pipeline | -| Turn continuation | Stop after ordinary continuation folding | A committed terminal output must end the turn | +| Turn continuation | Conclude through the committed tool result | A committed terminal output must end the turn | -`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output listens on the ordinary serial `agent/turn-stop` fold after normal continuation and steering decisions; no public `strictSerial()` dispatcher is needed for the typed listener contract. +`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output marks its own execution with `concludesTurn`, so terminality is data on the authoritative result rather than a separate hook decision. ### Skill and approval services trust typed callers 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 5b6b9b1ca5..8b1332c23c 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 @@ -150,7 +150,7 @@ sequenceDiagram 每个拆除请求加入一条记忆化路径。顺序为: 1. 停用创建或驱动,让同步发布完成。 -2. 停止并排空 driver,包括空闲注入刷新。 +2. 停止并排空 driver,丢弃仍处于待处理状态的注入。 3. 分离 agent。 4. 分离会话。 5. Dispose agent 作用域。 @@ -238,7 +238,7 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 -一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。提交后,普通串行的 `agent/turn-stop` 监听器在 continuation 和 steering(中途引导)已折叠之后返回停止决策。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 +一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。成功的结构化输出执行会调用 `exec.concludeTurn()`,因此其自身不可变结果携带 `concludesTurn: true`,循环在该步骤结束工具循环。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 @@ -250,9 +250,9 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 |---|---|---| | 工具 pre-policy | 单调拒绝 | 后续监听器不得重新允许已被拒绝的调用 | | 工具结果 | 观察不可变的已提交结果 | 结构化输出必须仅提交实际逃出流水线的结果 | -| 轮次 continuation | 在普通 continuation 折叠之后停止 | 已提交的终端输出必须结束轮次 | +| 轮次 continuation | 通过已提交工具结果终止 | 已提交的终端输出必须结束轮次 | -`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出监听普通串行的 `agent/turn-stop` 折叠,在正常 continuation 和 steering 决策之后;类型化的监听器契约不需要公开的 `strictSerial()` dispatcher。 +`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出在自身执行上标记 `concludesTurn`,因此终止性成为权威结果上的数据,而不是独立 hook 决策。 ### Skill 和 approval 服务信任类型化调用方 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 46f0a1cd41..460495cbeb 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md 2026-07-12-scoped-layers-store.md: c5186d1652bca617eed62ec02937f2d055ea727c -2026-07-12-scoped-layers-store.zh.md: 3183811be553428ebcd8f59f15989c44d458b477 +2026-07-12-scoped-layers-store.zh.md: fbe95ab6c7b3391a70e34b83889bf9f8adeb6ba1 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 3183811be5..fbe95ab6c7 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 @@ -20,7 +20,7 @@ agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.m ## 决策 -`@deepseek-ai/dsh-scope` 提供与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。 +`@deepseek-ai/dsh-scope` 提供与键类型无关的 `store.ts` 实现模块。该包继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。 `ScopeLayer` 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;`ScopedLayers` 负责构造、选择、生命周期挂接、通知和聚合回收。 @@ -77,7 +77,7 @@ export class AnonymousEntries { - `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换。 - `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界。 - `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。 -- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。 +- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有在整个层的 `ScopeLayer.isEmpty()` 返回 true 后,helper 才会删除专属层。 - `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。 ## 注册表迁移 @@ -100,7 +100,7 @@ export class AnonymousEntries { **注册方法上的显式 scope 参数。** 分开的可见性与属主输入让不匹配的生命周期成为可表达状态,而遗漏 scope 则会静默变成全局登记。 -**接受完整的 Cordis `Effect` union。** 七个登记口都没有异步 setup、多份 undo 或独立 settlement 边界。通用规范化会在没有现有消费者需要它时重复 Cordis 的生命周期 machinery。 +**接受完整的 Cordis `Effect` union。** 七个登记口都不涉及异步 setup、多份 undo 或独立结算边界。若没有现有消费方需要,通用规范化只会重复实现 Cordis 的生命周期机制。 **暴露 `ScopedLayers.values()`、`ScopedLayers.keys()` 或全局放行谓词。** 这些操作会编码消费方特有的活遍历或物化策略,以及过滤策略。直接遍历条目表可保留显式的活语义,`merge()` 覆盖共享的命名遮蔽操作,而 `ToolRegistry` 继续保有功能更丰富的私有解析器。 @@ -121,6 +121,6 @@ export class AnonymousEntries { ## 验证 - `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。 -- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。 +- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且经过排序的视图、直接执行和生命周期销毁。 - 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。 - 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema 与提示词组装的回归边界;人类命令由 TUI 覆盖。实现不会更新任何预期 transcript(文本记录)。 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 c34acd2db1..465a658ca8 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-14-provider-routed-llm-adapters.md: 1bd9197667f6e49c5025c98b4a77500f78595c2b -2026-07-14-provider-routed-llm-adapters.zh.md: 4d57f2cb33ac296500a4a19771ea493621ff93f6 +# 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: 27277280e423553f79d5a34f512b673413f495ff +2026-07-14-provider-routed-llm-adapters.zh.md: aeb09a500d5750ef2793bc9a7fc09834055a56a4 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 1bd9197667..27277280e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -40,7 +40,7 @@ pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` reje Assistant messages carry provider-neutral provenance containing the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records this provenance and `deriveMessages()` returns it with the assistant message. User, system, context, and tool-result messages carry no assistant provenance. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload. -A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches it to the assistant provenance only when the post-`agent/step-result` content is structurally equal to the assembled provider output. A listener that rewrites content keeps the provider/model provenance but loses the now-stale replay state. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. +A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant provenance without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmService` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content and provenance. 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 4d57f2cb33..aeb09a500d 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 @@ -8,9 +8,9 @@ Status: implemented `dsh-llm` 按精确模型名称注册适配器。插件在 Cordis 启动时提供模型列表,`LlmService` 为列表中的每个字符串保存一个适配器,`GenerateOptions.model` 同时选择适配器与提供方模型。两个正式适配器都只面向相同的两个 DeepSeek 模型时,这种方式可以工作,但它混淆了两个独立决策:由哪个上游提供方承接请求,以及该提供方应运行哪个模型。 -这种混淆使提供方网关无法提供开放的模型目录。例如,OpenRouter 是一个包含大量模型 ID 的提供方,私有 OpenAI 兼容端点也可能在不修改 Harness 插件树的情况下增加模型。目前,每个新选择的模型都必须在插件启动期间完成注册。同一个模型 ID 还可能存在于多个提供方中,因此仅按模型注册无法表达调用方预期使用的提供方。 +这种混淆使提供方网关无法提供开放的模型目录。例如,OpenRouter 是一个包含大量模型 ID 的提供方,私有 OpenAI 兼容端点也可能在不修改 harness 插件树的情况下增加模型。目前,每个新选择的模型都必须在插件启动期间完成注册。同一个模型 ID 还可能存在于多个提供方中,因此仅按模型注册无法表达调用方预期使用的提供方。 -`dsh-llm-pi-ai` 没有暴露 pi-ai 的提供方抽象。它以内联方式构造 DeepSeek `openai-completions` 模型,应用 DeepSeek 专用的 payload 补丁,并将每条回放的助手消息标记为 DeepSeek。pi-ai 自身提供提供方/模型目录,能够选择 `openai-responses`、`anthropic-messages`、`google-generative-ai` 等 API,并保留提供方专用的响应 ID,以及后续轮次所需的推理和工具签名。Harness 转换丢弃了这些来源信息,因此仅将内联模型替换为目录查询,会导致同模型回放与跨提供方移交不完整。 +`dsh-llm-pi-ai` 没有暴露 pi-ai 的提供方抽象。它以内联方式构造 DeepSeek `openai-completions` 模型,应用 DeepSeek 专用载荷补丁,并将每条回放的助手消息标记为 DeepSeek。pi-ai 自身提供提供方/模型目录,能够选择 `openai-responses`、`anthropic-messages`、`google-generative-ai` 等 API,并保留提供方专用的响应 ID,以及后续轮次所需的推理(reasoning)和工具签名。harness 转换丢弃了这些来源信息,因此仅将内联模型替换为目录查询,会导致同模型回放与跨提供方移交不完整。 适配器配置同样假定只存在一个 DeepSeek API 密钥和端点。通用后端需要为各提供方分别配置凭据和端点覆盖,同时继续由 pi-ai 处理 AWS、Google ADC、OAuth 等环境认证机制。 @@ -18,9 +18,9 @@ Status: implemented ### 提供方作为适配器注册键 -`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。 +`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都是已记录请求头的一部分。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。 -`LlmService` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并将整组注册作为一个 effect 释放。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 +`LlmService` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时以 `DUPLICATE_ADAPTER` 拒绝注册,并将整组注册作为一个 effect 统一 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP(Agent Client Protocol)模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。 @@ -28,29 +28,29 @@ Status: implemented ### 显式 pi-ai 提供方配置 -`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤 seam 上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 +`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤 seam 上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 -适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。 +适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 harness 强制归因 headers 合并;发生保留名称冲突时,以 harness 归因为准。适配器不再维护 DeepSeek 专用载荷重写或提供方协议矩阵。 -pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。 +pi-ai 的通用流选项不支持停止序列。若 harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。 ### 持久化助手来源信息与回放状态 助手消息携带提供方无关的来源信息,其中包含请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些来源信息,`deriveMessages()` 返回助手消息时也会包含这些信息。用户、system、context 与工具结果消息不携带助手来源信息。provider/model 字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。只有当 `agent/step-result` 处理后的内容与提供方组装输出在结构上相等时,agent loop 才会把回放状态附加到助手来源信息。监听器重写内容后,provider/model 来源信息仍会保留,但已经陈旧的回放状态会被移除。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到组装后的助手来源信息,不再暴露响应改写 hook。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 -pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。 +pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。 该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 来源信息中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 ### 在所有请求生产方中传播目标 -每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。 +每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP 和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。 -压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用 envelope 记录两个字段。 +压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用封装记录两个字段。 JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。 @@ -68,7 +68,7 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方 **每个提供方挂载一个 pi-ai 插件实例。** 独立实例可以隔离配置,但会重复插件声明,也无法实现配置注册的原子性。每个请求本就向同一个适配器提供 provider,因此经过验证的配置映射具有更小的生命周期接口。 -**接受任意内联 pi-ai 模型描述符。** 这种方式可支持目录外的私有模型 ID,但会将 pi-ai 的模型与兼容性 schema 暴露为 Harness 配置,并要求适配器验证协议专用组合。当前版本通过覆盖目录模型的 `baseURL` 支持自定义端点;只有实际出现目录外部署需求后,才会另行决策是否支持自定义描述符。 +**接受任意内联 pi-ai 模型描述符。** 这种方式可支持目录外的私有模型 ID,但会将 pi-ai 的模型与兼容性 schema 暴露为 harness 配置,并要求适配器验证协议专用组合。当前版本通过覆盖目录模型的 `baseURL` 支持自定义端点;只有实际出现目录外部署需求后,才会另行决策是否支持自定义描述符。 ## 影响 @@ -84,7 +84,7 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方 - 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、单次请求的选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、调用方取消、空闲超时导致的传输终止、内容重写,以及同一实例与不同实例间的回放分发。 - 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化 provider/model 元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。 -- 公共 JSDoc、package README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。 +- 公共 JSDoc、包的 README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。 ## 风险 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 d5e16246fe..233164d4e4 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md 2026-07-15-agent-initiator-scope.md: 69648100e76cfc212469854188d664357fec22f1 -2026-07-15-agent-initiator-scope.zh.md: 835d7a5b2ab6d2d6fce7971de4fd9d6c69e50d77 +2026-07-15-agent-initiator-scope.zh.md: 505d198ccd2a54af1a15fc1ad6c03b27d217eca0 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 835d7a5b2a..505d198ccd 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 @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 +harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。 @@ -22,9 +22,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 -`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose 后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 +`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 -发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所有前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 +发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所属的前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 @@ -32,9 +32,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 验证 -Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 +Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise 的精确身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 -测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 +测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与日志中记录的参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise **包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。 -**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。 +**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 harness 不具备的串行保证下才正确。 **从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。 @@ -56,7 +56,7 @@ Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise ## 后果 -深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,AgentLoop 不增加新的必需服务,HMR 或根 Context dispose 会在禁用 ALS 前完成排空。 +深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,AgentLoop 不增加新的必需服务,HMR(热模块替换)或根 Context dispose 会在禁用 ALS 前达到完全停稳。 该依赖不会出现在函数签名中,并且携带一个具有控制能力的 Agent 对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 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 89204b0a67..faade67459 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# 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: 9edc723b0dfafeaf395eb9325373835138ddbc41 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 677d9e2a200d488fa9fc27fc2a922dc8f1f871d1 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aefd0af3e4de8fac4f5819e0ee279d3343aec6be 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 677d9e2a20..aefd0af3e4 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 @@ -8,11 +8,11 @@ Status: implemented ## 问题 -基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmService` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使请求接缝已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。 +基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmService` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使请求 seam 已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。 模型发现不能变成请求校验。手写 DeepSeek 适配器会把任意模型 ID 原样转发给公开或私有端点,而 pi-ai 的有限安装目录则是其自身请求解析的权威依据。将共享目录视为白名单,会破坏提供方路由需要保留的私有端点能力。 -ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多个路由下;切换全局适配器或 agent 模板会让一个编辑器会话的选择泄漏到其他会话。Prompt 变量与请求路由必须同时变化;如果选择发生在异步 prompt 组装期间,不能让 `{{model}}` 表示一个模型、实际请求却到达另一个模型。 +ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多个路由下;切换全局适配器或 agent(智能体)模板会让一个编辑器会话的选择泄漏到其他会话。提示词变量与请求路由必须同时变化;如果选择发生在异步提示词组装期间,不能让 `{{model}}` 表示一个模型、实际请求却到达另一个模型。 ## 决策 @@ -20,7 +20,7 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 `LlmAdapter` 增加 `providerInfo(provider)` 与异步 `listModels(provider)` 方法。其提供方无关结果分别为 `LlmProviderInfo { id, name }` 和 `LlmModelInfo { provider, id, name, description? }`。默认实现以路由名称作为提供方名称,并且不展示模型,从而保持现有适配器行为。 -`LlmService.listProviders()` 按注册顺序返回分离后的元数据。`LlmService.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回分离后的值。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。 +`LlmService.listProviders()` 按注册顺序返回元数据副本。`LlmService.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回值的副本。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。 目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。 @@ -32,11 +32,11 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 -### Prompt/请求一致性与持久化 +### 提示词/请求一致性与持久化 -`installAgentLlmTarget`(位于 `dsh-agent`)为前门拥有的目标安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。Prompt 组装在每个 step 对所选组合做一次快照,在下游 prompt 监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个 step 生效,而不会让 prompt 文本与路由分裂。其他调用配置字段保持不变。 +`installAgentLlmTarget`(位于 `dsh-agent`)为前门拥有的目标安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的事实来源。当所选目标真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当所选目标真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 @@ -46,17 +46,17 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建 **把选择存进 `AgentOptions` 或 `LlmService`。** 它们是创建级或部署级对象。改动它们会把并发会话耦合在一起,并绕过有日志记录的 `agent/request` 替换路径。 -**立即持久化一个新的模型选择会话事件。** 未被使用的 UI 选择尚未影响任何模型请求。在目标被消费时记录现有请求头,既保持“模型可见当且仅当有日志”的规则,又不会引入第二个事实来源。 +**立即持久化一个新的模型选择会话事件。** 未被使用的 UI 选择尚未影响任何模型请求。在目标被消费时记录现有请求头,既保持「模型可见当且仅当有日志」的规则,又不会引入第二个真源。 ## 结果 -- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到核心接缝。 -- 目录消费者必须把缺失理解为“未展示”,而不是“请求无效”。 +- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到核心 seam。 +- 目录消费方必须把缺失理解为「未展示」,而不是「请求无效」。 - pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留对任意模型的支持。 - 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围。 - 请求头与基于提供方路由的会话形态保持兼容;不需要新的 JSONL 事件或格式版本。 -- 目录读取可以是异步的,且每个调用方都会收到分离后的值。 +- 目录读取可以是异步的,且每个调用方都会收到值的独立副本。 ## 测试 -单元测试覆盖目录分离与错误元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及 prompt 变量对齐;按 agent 的隔离来自监听器安装在 agent 作用域上下文这一事实。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复。 +单元测试覆盖目录值副本与格式错误的元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复。 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 005f23b151..11073946ea 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md 2026-07-15-lsp-capability-seam.md: d96b3a9c5139c1455a51f4fff793293d7b5a11c0 -2026-07-15-lsp-capability-seam.zh.md: 54dd32e46dded5722dda910e9138879d3f99de07 +2026-07-15-lsp-capability-seam.zh.md: 256b293f213acb06588f3ef6c655242b1c8fd5b9 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 54dd32e46d..256b293f21 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 @@ -1,4 +1,4 @@ -# Agent Note: LSP 能力服务边界与面向模型的查询工具 +# Agent Note: LSP 能力 seam 与面向模型的查询工具 Status: implemented @@ -14,7 +14,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 ## 决策 -将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: +将 LSP 建成由三个包组成的能力 seam,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。 @@ -98,9 +98,9 @@ interface LspToolInput { 工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 -位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并将每个完整渲染结果(包括截断元数据)限制在该字符数内。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 -与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 +与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示器仍为纯函数。 ## 超时归属 @@ -131,7 +131,7 @@ interface LspToolInput { `dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 -初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 +初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作能力与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。 @@ -143,7 +143,7 @@ interface LspToolInput { 诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。 -本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。 +本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区,并执行私有缓存写入与临时写入的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。 ## 备选方案 @@ -155,7 +155,7 @@ interface LspToolInput { **公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。 -**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。 +**将信号包装为服务边界专用的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。 **通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。 @@ -187,7 +187,7 @@ interface LspToolInput { ## 影响 -各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 +各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的「索引完成」信号。不具备兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。 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 dd16a5f327..80fe86f2bc 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 3496364663c1f73b8161461d1a229b19d9730c6d -2026-07-15-replay-token-meter-service.zh.md: 0bc4d9decac36bd5674cd0fb04f82fdcd277554e +# 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: c0f4b467ad0013dd4ac0a0301281b011ea8c261c +2026-07-15-replay-token-meter-service.zh.md: c1d81dc0e76ee687ced5c23d26197627551e1ced diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 3496364663..c0f4b467ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -36,7 +36,7 @@ Automatic compaction uses one unified measurement for each threshold-and-retenti Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. -Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. +Automatic pressure runs at `agent/pre-step` before request derivation and measures the canonical durable envelope produced under the provider/model actually selected by the preceding `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. ## Testing 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 0bc4d9deca..c1d81dc0e7 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 @@ -6,17 +6,17 @@ Status: implemented ## 问题 -上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 +上下文压力并不只对压缩(compaction)有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。 ## 决策 -### 一个具体的 LLM 家族服务 +### 一个具体的 LLM(大语言模型)家族服务 `@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `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.md)。 ### 逐会话回放折叠 @@ -30,22 +30,22 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket ### compact-basic 消费计量,但不拥有计量 -`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。配置、区域事务与摘要器分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类 hook。单例计量器一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 +`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。配置、区域事务与摘要分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类钩子。单例计量器一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务会在追加持久 `compact/start` 锁后执行计量,在异步摘要完成后再次计量,随后比较分离的表层节点向量。期间发生的表层变更会阻止替换;`logRevision` 可以因无关的纯日志事实而推进,而不会使未变的选定范围失效。 压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 -自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 +自动压力检查在请求派生前运行于 `agent/pre-step`,并计量前一个 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 ## 测试 -单元测试覆盖固定估算、信封失效与锚点替换、回放边界、不可变快照、已路由压力、收敛、溢出 generation 证明与回滚。真实 Loader/Include fixture 验证零配置 token-meter 与 compact-basic 按依赖顺序加载的路径。 +单元测试覆盖固定估算、信封失效与锚点替换、回放边界、不可变快照、已路由压力、收敛、溢出 generation 证明与回滚。真实 Loader/Include fixture(测试前置数据)验证零配置 token-meter 与 compact-basic 按依赖顺序加载的路径。 ## 考虑过的替代方案 - **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 -- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 +- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来的 seam,同时避免推测性的包与配置。 - **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compact-basic 则拥有消费方专用的阈值与保留策略。 - **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。 - **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 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 11c3d9b5a2..9c5d00eae0 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 -2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc +2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 15085a1da2..cce649976c 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -16,11 +16,11 @@ Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail. -AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through prompt processing, prompt assembly, every step, model and tool execution, continuation, and `agent/turn-stop`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. +AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after ## Verification -Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at prompt submission, system-prompt assembly, session prefix, pre-step, request, model stream, step result, tool execution, continuation, and terminal stop; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. +Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence. 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 e945b0fea5..6f8b83fdb4 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 @@ -1,4 +1,4 @@ -# Agent Note:显式轮次取消能力 +# Agent Note: 显式轮次取消能力 Status: implemented @@ -12,27 +12,27 @@ Status: implemented ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这份类型化同进程契约中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性契约。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程 seam 中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性契约。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 -正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。Session seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 +正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 -AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策和 `agent/turn-stop`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。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 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 -取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 `Promise.race` 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、句柄 dispose 和作用域清理才会报告静止状态。 +取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 `Promise.race` 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、句柄 dispose 和作用域清理才会报告完全停稳。 ## 验证 -契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 +契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 -发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 +发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。 ## 考虑过的替代方案 @@ -52,4 +52,4 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。 -显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 +显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的完全停稳仍然真实。 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 77e3b8c14a..2f56298d57 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md 2026-07-19-cooperative-tool-cancellation.md: be237f6ca9475699bb4af76896772a1a7409033d -2026-07-19-cooperative-tool-cancellation.zh.md: 9ad212c2073063ccb0c838c08ab8f89c9285b26b +2026-07-19-cooperative-tool-cancellation.zh.md: dbc74588931a2ae75678b0026df7a8175b0d20b6 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 9ad212c207..dbc7458893 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 @@ -10,7 +10,7 @@ Status: implemented 流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合。 -取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的使用方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。 +取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的消费方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented `ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。 -注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、队列、模型与工具 JSON、持久化与文件、worker、进程和线协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。 +注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。 ### 可变性由流水线阶段决定 @@ -28,9 +28,9 @@ Status: implemented ### 取消代码记录是否发生过调度 -`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'` 和 `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已经开始。 +`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'` 和 `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已调用。 -`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop 跳过的同批调用。 +`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop(智能体循环)跳过的同批调用。 `ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 @@ -42,11 +42,11 @@ Status: implemented 工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 -这项决策只要求工具调用接缝携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力接缝中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 +这项决策只要求工具调用 seam 携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖未调度同批调用的持久化配对结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 @@ -54,20 +54,20 @@ Status: implemented **保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。 -**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程接缝,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。 +**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程 seam,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。 **添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责。 **向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。 -**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套运行时作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 +**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 **让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 - TypeScript 会拒绝所有缺少 `signal` 的 `ToolExecutionInput`、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试。 -- 持久化结果的使用方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`)。 +- 持久化结果的消费方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`)。 - 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为。 - 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用。 - 下游能力接口保持不变,直到关联的提议 Agent Note 被接受并实现。 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 ca90cebb0e..bdb07d5f8a 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: 7ad2a2403eb9962b369b016070e8ca378ed55c60 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 90850c469f1444e7f6cd105551e6cc21920e91d9 +2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 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 7ad2a2403e..34077302c5 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 @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) -> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). ## Problem @@ -15,7 +15,7 @@ We need a UI integration layer. Beyond the existing ACP/stdio baseline, more pro That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. -At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. +At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. ## Decision @@ -64,7 +64,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | -| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | | Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | @@ -88,7 +88,7 @@ The sections from here down are the protocol body carried by the front layer (`d ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -99,7 +99,7 @@ The sections from here down are the protocol body carried by the front layer (`d |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`. @@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess ### Frames (server→client, named unions) -Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row: +Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: | frame type | payload | when | |---|---|---| @@ -216,7 +216,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | 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 90850c469f..bc51542ac8 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+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 ## Problem @@ -14,7 +14,7 @@ Status: implemented 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 -同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 +同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 ## Decision @@ -62,7 +62,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | -| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | | 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | @@ -86,7 +86,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -97,7 +97,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -214,7 +214,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/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 61e6a93e23..5376a626e6 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: b1f777172774f1cf8fef4d9494f15b38064d0c73 -2026-07-19-gui-web-client-architecture.zh.md: e43151b7d5ff096d574c786e3aae107523d22c96 +2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570 +2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1 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 b1f7771727..1a91d88818 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 @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. **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). @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory. +- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) 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 e43151b7d5..5c0bacde98 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 @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载(HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。 +- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 0379a79e52..e897122368 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md 2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a -2026-07-19-package-invariant-runtime-contracts.zh.md: bd2f440d5dce15b352e7bcea0d1243400d290f11 +2026-07-19-package-invariant-runtime-contracts.zh.md: a734b1a3deb739c214d1c4c2565fc697b5e4b89b diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index bd2f440d5d..a734b1a3de 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -包自有不变量接缝让发布和注册实现了全覆盖,但最初的生成基线允许空安装器。后续方案又用针对插件名称、注入、effect、服务方法和固定纯函数示例的通用断言替代这些空实现。这些断言虽然让每个 companion 都能执行,却没有提高系统安全性:TypeScript、Cordis 启动、包测试和模块加载测试已经约束这些形状,而不变量服务应当发现不可能出现的运行时状态。 +包自有不变量 seam 让发布和注册实现了全覆盖,但最初的生成基线允许空安装器。后续方案又用针对插件名称、注入、effect、服务方法和纯工具库中的固定示例的通用断言替代这些空实现。这些断言虽然让每个 companion 都能执行,却没有提高系统安全性:TypeScript、Cordis 启动、包测试和模块加载测试已经约束这些形状,而不变量服务应当发现不可能出现的运行时状态。 -有用的运行时不变量会关联时间上的多个观测,或关联可变数据结构中的多个部分。例如:终止事件没有对应的开始事件、LLM delta 指向未打开的 block,或持久化结果的身份与请求不同。仅确认声明的方法存在、插件名称符合预期,或常量示例仍返回已知值,都不属于这种关系。 +有用的运行时不变量会关联时间上的多个观测,或关联可变数据结构中的多个部分。例如:终止事件没有对应的开始事件、LLM(大语言模型)delta 指向未打开的块,或持久化结果的身份与请求不同。仅确认声明的方法存在、插件名称符合预期,或常量示例仍返回已知值,都不属于这种关系。 有些包确实没有可持续观测的关系。纯工具、仅负责组合的包、薄适配器、可执行入口和测试支持包可能仍有重要契约,但类型检查、加载检查、聚焦单元测试或集成测试更适合执行这些契约。强迫这些包添加合成运行时断言,只会让实现围绕通过门禁优化,而不是检测损坏。 @@ -23,7 +23,7 @@ Status: implemented 空形式是明确的架构结论,不是生成占位符。如果后续包变更引入可变状态或事件协议,就必须用相应检查替换该说明。 -中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、释放和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 +中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、dispose(资源释放)和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 ### 已实施的检查 @@ -31,29 +31,29 @@ Status: implemented | 所有者 | 运行时关系 | |---|---| -| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 | +| `dsh-session` | 序号严格递增、轮次/步骤包围关系,以及同一步骤内的工具调用/工具结果配对。 | | `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 | -| `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | -| `dsh-agent-loop` | 从 session 事件日志重建带显式标记的冻结 loop 请求。 | -| `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | -| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 | +| `dsh-scope` | 作用域事件必须携带 carrier,且路由 subject 保持一致。 | +| `dsh-agent-loop` | 从会话事件日志重建带显式标记的冻结 loop 请求。 | +| `dsh-llm` | 流中块的文法、delta 类型/索引匹配、单次 usage、块闭合和终止 finish。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开轮次中最近关闭的步骤;每个步骤的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | -| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | -| `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | -| `dsh-hook-protocol` | hook invocation/result 的关联、dialect、身份和 duration 约束。 | +| `dsh-system-prompt` | 权威 assembly 中 section、工具和 variable 的数据约束。 | +| `dsh-compact` | 压缩(compaction)start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | +| `dsh-hook-protocol` | 钩子 invocation/result 的关联、dialect、身份和 duration 约束。 | | `dsh-sandbox-policy` | 持久化 `sandbox/mode` 事件必须使用封闭的 sandbox-mode 词表。 | | `dsh-fs` | 文件系统决策/观测事件必须携带可用的 target 和 version 身份。 | -| `dsh-goal` | 持久化目标快照保持来源归属、渲染内容、修订号、生命周期和时间戳关系,并保证已准入的目标回合连续编号。 | +| `dsh-goal` | 持久化目标快照保持来源归属、渲染内容、修订号、生命周期和时间戳关系,并保证已准入的 Round 连续编号。 | | `dsh-goal-session` | 目标来源的继续执行消息必须匹配根据此前持久化目标状态重建的提示词。 | -| `dsh-subagent` | provider add/remove 和 child start/end 事件必须保持身份与配对。 | +| `dsh-subagent` | 提供方 add/remove 和 child start/end 事件必须保持身份与配对。 | | `dsh-permission` | 持久化 permission 决策必须引用当前 permission 表中的 preset。 | | `dsh-user-approval` | approval asked/decided 记录按 call 配对,并使用有效 outcome 和 policy。 | -| `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | -| `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | -| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | +| `dsh-workflow` | 工作流和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | +| `dsh-tasks` | 当前与终态 task 快照保持 id/kind、owner、status 和 timestamp 关系。 | +| `dsh-tool-todo` | 持久化全量快照使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配会话当前打开的轮次、下一个步骤开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | -基于 session 的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 +基于会话的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威实时事件边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 @@ -75,4 +75,4 @@ Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantServi - 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。 - 类型声明、Cordis 可加载性、插件 metadata、服务方法形状和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 - 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。 -- 原有 selection、blocklist 优先级、重复所有权、回滚、释放和 HMR 服务契约保持不变。 +- 原有 selection、blocklist 优先级、重复所有权、回滚、dispose 和 HMR(热模块替换)服务契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 3c005a6deb..544e7415ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-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-19-package-owned-invariant-service.md 2026-07-19-package-owned-invariant-service.md: e32efe9f6b3ce6b782c61db56d928e87c160dc9a -2026-07-19-package-owned-invariant-service.zh.md: 60edaa3f6009acc516017683232ca0c07f64ec0d +2026-07-19-package-owned-invariant-service.zh.md: f134541c78f849fa085bebef527b57f911af0748 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 60edaa3f60..f134541c78 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 包拥有的不变式服务接缝 +# Agent Note: 包拥有的不变式服务 seam Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml index d63e6d4b88..acb9d72ef1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-zstandard-jsonl-session-logs.md -2026-07-19-zstandard-jsonl-session-logs.md: 287ec94a91101850e9343d36ffd27870daf1333b -2026-07-19-zstandard-jsonl-session-logs.zh.md: 4e578432640651de1eb1977229b7cdd462766c24 +2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254 +2026-07-19-zstandard-jsonl-session-logs.zh.md: 061d7fcb55c775eed10e99bae47777d32cc8eee1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md index 287ec94a91..93fc20f931 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -28,7 +28,7 @@ First materialization compresses the two initial frames before opening the tempo ### Read, listing, and crash recovery -A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially with Node's default `ZSTD_e_end`, which requires frame completion and validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. +A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are independently checksum-validated and passed through the [large-session restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md), which owns decoder reuse, cooperative yielding, and incremental JSONL scanning. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs. @@ -53,5 +53,5 @@ The shared persistence and coordinator contracts run against both encodings. Bac - Ordinary session roots store `.jsonl.zstd` and retain append-only, fsync, rollback, and interrupted-turn recovery semantics. - Raw JSONL remains a deliberate configuration, but changing encoding requires a fresh/separate root or selecting the mode that matches existing artifacts. - One frame per durable batch adds bounded framing/checksum overhead and allows header-only listing plus repair from an exact append boundary. -- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames explicitly. +- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames through the [restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md). - The implementation depends on Node's experimental built-in Zstandard API without an npm dependency; the supported-version compatibility gate makes drift visible. diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md index 4e57843264..061d7fcb55 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -28,7 +28,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 ### 读取、列举与崩溃恢复 -帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端使用 Node 默认的 `ZSTD_e_end` 独立且按顺序解压完整帧;该模式要求帧完整并验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 +帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。完整帧会独立验证校验和,再进入[大型会话恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md);该流水线负责复用解码器、协作式让出事件循环和增量扫描 JSONL。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 @@ -53,5 +53,5 @@ CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配 - 普通会话根目录存储 `.jsonl.zstd`,并保留仅追加、fsync、回滚与中断轮次恢复语义。 - 原始 JSONL 仍是显式配置,但切换编码需要使用全新或单独根目录,或者选择与既有产物匹配的模式。 - 每个持久批次一个帧会增加有界的帧与校验和开销,同时支持仅头部列举和从精确追加边界开始修复。 -- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会显式遍历各帧。 +- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会通过[恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md)遍历各帧。 - 实现依赖 Node 的实验性内置 Zstandard API,但不增加 NPM 依赖;受支持版本兼容性门禁会暴露 API 漂移。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 17d864cb57..f8ce3ee5f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.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-20-canonical-tool-output-contract.md 2026-07-20-canonical-tool-output-contract.md: b2de9480d2659153dfb8a76ee07438d12e5b07c3 -2026-07-20-canonical-tool-output-contract.zh.md: 1ae2654fb1e1d6913bc91c4aeb380dc533a6b258 +2026-07-20-canonical-tool-output-contract.zh.md: 1852ae849aec27ef3af28895e902d840af5de14d diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index 1ae2654fb1..1852ae849a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -1,4 +1,4 @@ -# Agent Note:规范工具输出契约 +# Agent Note: 规范工具输出契约 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 -持久会话契约将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 +持久会话契约将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩(compaction)和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 ## 决策 @@ -22,9 +22,9 @@ output: { } ``` -`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。 +`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持的原始 schema 的定义,不提供兼容旧式内容返回值的路径。 -每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果只归属于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。 +每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。围绕 `tools/execute` 的包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果的来源归属仅限于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。 ```ts ignore-check type ToolExecutionResult = @@ -49,18 +49,18 @@ type ToolExecutionResult = | `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` 或 `{ kind: "hover", hover }` | | `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` | | `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | 公开会话快照、有界的读取/发送 DTO、信号/关闭操作结果,或后台任务句柄 | -| `task_output` / `task_list` / `task_kill` | 不含所有者或通知账务字段的公开任务快照 | +| `task_output` / `task_list` / `task_kill` | 不含所有者或通知管理信息的公开任务快照 | | `subagent` | 后台任务句柄或 `{ kind: "foreground", runId, output: JsonValue[] }` | | `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | | `skill` | `{ name, provider, resourceBase?, content }` | | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的临时 Plugin 句柄 | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的临时插件句柄 | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | -提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影仍只保留配置指定的第一页,并尽力将完整展示内容写入落盘文件。通用落盘机制会前置注册其 post-execute 监听器,并让该监听器先向后委托,因此无论插件加载顺序如何,普通工具自有的异步投影都会在通用字节数上限处理之前完成。文件系统变更工具根据 `args` 和规范的变更前/后值推导可回放的 diff 元数据,不再由工具主体返回 UI 状态。 +提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影会保留配置指定的第一页,并尽力将其写入落盘文件。通用落盘机制会前置注册其 post-execute 监听器,并让该监听器先向后委托,因此无论插件加载顺序如何,普通工具自有的异步投影都会在通用字节数上限处理之前完成。文件系统变更工具根据 `args` 和规范的变更前/后值推导可回放的 diff 元数据,不再由工具主体返回 UI 状态。 MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredContent? }` 保留协议内容块。当公布的 `outputSchema` 属于受支持的原始子集时,系统会强制校验;不受支持的 schema 则回退为 `JsonValue`,而不会假装已完成校验。Native 渲染仍使用现有的 MCP 到 `ContentBlock` 投影,MCP `isError` 则会变为失败的工具结果。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml index d6ca73e9e0..20abdeec64 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-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-20-routed-model-context-and-compaction-policy.md 2026-07-20-routed-model-context-and-compaction-policy.md: 6a2626d4eea3e30533199320b6b0ed5b05e7fd28 -2026-07-20-routed-model-context-and-compaction-policy.zh.md: 2881b2d4205359b715303622268ee9218dd75831 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: 128563718554e2f8ed49f933aceb77cb18623dee diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md index 2881b2d420..1285637185 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -8,19 +8,19 @@ Status: implemented 当一个进程把请求路由到不同容量的模型时,压缩不能安全地应用同一个全局上下文窗口。相同模型 id 也可能存在于多个提供方下,适配器还可能接受不在建议目录中的动态 id。错误容量要么让压缩触发过晚并造成原本可避免的溢出,要么让压缩触发过早并丢弃有用上下文。 -两个直观的配置归属方都无法独立解决问题。Compact-basic 是可选插件,不知道适配器接受哪些模型。LLM 适配器拥有模型路由,但不能依赖可选压缩插件,也不应吸收消费方专用的阈值、保留、摘要器与重试策略。该设计既需要权威容量事实和可选的逐目标压缩策略,又不能建立第二套模型注册表。 +两个直观的配置归属方都无法独立解决问题。Compact-basic 是可选插件,不知道适配器接受哪些模型。LLM(大语言模型)适配器拥有模型路由,但不能依赖可选压缩插件,也不应吸收消费方专用的阈值、保留、摘要器与重试策略。该设计既需要权威容量事实和可选的逐目标压缩策略,又不能建立第二套模型注册表。 ## 决策 ### 适配器拥有精确路由容量 -`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmService.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。 +`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmService.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回与适配器内部状态分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。 手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 256,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 ### Token 计量保持模型无关 -`dsh-token-meter` 没有配置,也没有模型 profile。它拥有一个固定回放折叠,并返回绝对估算 token 压力与逐位置表层价格。移除全局容量后,未加载 compact-basic 时仍可复用计量,同时避免让回放核算变成另一套模型注册表。 +`dsh-token-meter` 没有配置,也没有模型 profile。它拥有一个固定回放折叠,并返回绝对估算 token 压力,以及按位置排列的表层节点 token 估值。移除全局容量后,未加载 compact-basic 时仍可复用计量,同时避免让回放核算变成另一套模型注册表。 ### Compact-basic 解析目标规格 @@ -32,11 +32,11 @@ Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolici ### 目标专用压力错误仍保留可选组合 -缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大的平衡缩减,并在替换无法证明进展时保留原始提供方错误。 +缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大且平衡的缩减,并在替换无法证明进展时保留原始提供方错误。 ## 测试 -服务测试覆盖分离上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture 会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。 +服务测试覆盖与适配器内部状态分离的上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture(测试前置数据)会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml index 19a6b628c3..42b61e04c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md 2026-07-20-unified-json-value-schema-dsl.md: 5de3523eab15a91ea32dc09e2e239146fadea6f1 -2026-07-20-unified-json-value-schema-dsl.zh.md: 321136c31a6aa6c0268150fcde2d97dcdbb0ac58 +2026-07-20-unified-json-value-schema-dsl.zh.md: 8521e03beb7398ebfdf5e5cddae7a99471aa38d7 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md index 321136c31a..8521e03beb 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -1,4 +1,4 @@ -# Agent Note:统一 JSON 值 schema DSL +# Agent Note: 统一 JSON 值 schema DSL Status: implemented @@ -10,13 +10,13 @@ Status: implemented ## 决策 -`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。 +`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议形式。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。 显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。schema 记录只能包含自有且可枚举的字符串键,schema 数组必须是稠密的内建数组,系统只从自有属性读取受支持的关键字;因此,自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器,而子类和伪造构造函数的原型仍视为非普通对象。 -`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。 +`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表脱离引用,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。 -对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。 +对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为宿主拥有的 JSON 值,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。 ## 备选方案 @@ -34,4 +34,4 @@ Status: implemented - 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。 - 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 - 每个属性的 `required: true` 仍是工具作者契约;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。 -- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 +- 运行时测试和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 1604914ac0..46ec2eb556 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 -2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b +2026-07-22-slot-type-chain-implementation.md: 2f0ec32766100e492c68c474f8798be3df0a3d15 +2026-07-22-slot-type-chain-implementation.zh.md: 75e89d3f57b96a1699981123e8361c775db2b8a7 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index e88361701f..2f0ec32766 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -36,6 +36,8 @@ There is no separate slot-definition API. The `children` object both **declares Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`. +A contributor whose activation order is independent from the declaring entry uses `ctx.slots.inject(key, callback)` and keeps direct `register()` fail-loud. The declaration, contributor, replacement, and failure lifetimes are specified by the [slot declaration injection decision](2026-08-05-slot-declaration-injection.md). + `SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type"). ### Component props: four shares, each from its own source of truth diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 90473861c1..75e89d3f57 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -6,17 +6,17 @@ Status: implemented > 范围:Web 客户端 slot 体系的终版设计——UI 插件如何拼合页面、渲染权威落在哪里、组件 props 如何定型、业务活数据住在哪里。周边语境(装载链、对象层、服务)归 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 所有,其 slot 各节移交本文。 -## Problem +## 问题 -页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?业务活数据住在哪里,才能让流式更新恰好只重渲染订阅者——而不必每个插件自建一套订阅机械?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外? +页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?实时业务数据应存放在哪里,才能让流式更新恰好只重渲染订阅者,而不必让每个插件自建一套订阅机制?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外? -## Decision +## 决策 -一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** +一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** -### 'root' 是唯一的先验坑 +### 'root' 是唯一的先验 slot -`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。 +`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。 ### register 是唯一 API;children = 声明+授权+运行时 spec @@ -32,9 +32,11 @@ ctx.slots.register({ }, AppFrame) ``` -不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。 +不存在独立的 slot 定义 API。`children` 对象同时做两件事:**声明子 slot**,并**授权本组件渲染它们**——slot 是渲染树上的一个洞,因为有人要渲染它才存在,所以 slot 的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),slot 随之消亡、slot 内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。 -对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 +对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 + +激活顺序独立于声明条目的贡献方使用 `ctx.slots.inject(key, callback)`,并让直接调用 `register()` 继续大声失败。声明、贡献方、替换与失败各自的生命周期由 [slot 声明注入决策](2026-08-05-slot-declaration-injection.md) 规定。 `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 @@ -43,23 +45,23 @@ ctx.slots.register({ | 份额 | 类型 | 真源 | 内容 | |---|---|---|---| | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | -| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | +| 子 slot 渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | -| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use` 选择器 hook 到达(`InjectFace`) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留的 `hooks` 区域内,裸 observable 经绑定后以 `use` 选择器 hook 的形式到达(`InjectFace`) | -凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 +凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双重类型约束的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 -### chain kind:entry 自荐,首中即渲 +### chain kind:entry 自荐,首个匹配项负责渲染 -第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占坑者,chain 则由 entry 自荐——owner 只分派一份通用货币形态的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。 +第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占用 slot 的 entry,chain 则由 entry 自荐——owner 只分派一套格式统一的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。 -「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其 hook 与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的契约:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由、绝不铸对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。 +「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其 hook 与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的契约:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由,绝不创建新对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。 类型链上,chain entry 的 SlotMap 形状是 `{ kind: 'chain'; scope; owner }`,`owner` 即链的货币;`M`——`matched` prop 的类型——从 select 返回值推导(选择器收窄 union 成员时,`matched` 类型自动随之收窄),且组件位不参与 `M` 的推断,与钉住 inject 份额的 NoInfer 裁定同源(见下文裁定)。owner 侧,`renderSlotChain(key, owner, { fallback })` 与 `renderSlot` 同住 `PropsRenderSlots` 份额,其键域静态收窄到本 entry children 声明中 chain kind 的键(`ChainKeysOf`);分派现场只有一行,不含任何自有的派生或路由逻辑。 ### store 席位:引擎归框架,schema 归注册方 -框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例): +框架只拥有一套订阅机制:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例): ```ts ignore-check export function createChatStore() { @@ -76,11 +78,11 @@ export function createChatStore() { 一个工厂,三个消费点:① `register`——独占 store 直接传工厂;要共享实例,则在 `apply` 里调用一次工厂、把同一句柄传给多次 register(跨插件共享构造性不可能:句柄从不出包);② `PropsStore>` 推导出组件的 store 份额,零手写成员;③ 测试自己调用工厂并 `.create()` 出真引擎实例,把 `useSelector`/`actions` 直接当 props 喂进去——生产 outlet 走的正是同一条 `create` 路径,不存在第二套机械。 -store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会话一个实例,随会话生灭;root 坑→每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。 +store 的 scope **从挂载 entry 的 scope 推导**(session slot →每个会话一个实例,随会话生灭;root slot →每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。 -### inject:注册方的业务面,立足自己的 ctx +### inject:注册方通过自己的 ctx 提供业务接口 -inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 +inject 工厂只接收其声明所授权的形参——session slot 获得 `sessionId`,声明了 store 的获得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍不包含订阅机制。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。不得手写 hook,不得生成 ReactNode,也不得传递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 ### 数据界线纪律 @@ -88,9 +90,9 @@ hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStor ### 树上语境与渲染器安装缝 -`SessionProvider` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。 +`SessionProvider` 是框架组件,**以标配 slot 形式送达**:`children` 里声明了 session scope slot 的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 将树上下文作为仅供框架机制使用的隐式参数读取——即「身份出自 register 闭包、现场出自树位置」的分工。 -渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 +渲染位于一个 install seam 之后,因此 runtime 不依赖 React:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map`——账本、slot、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 ### 类型链实现裁定 @@ -99,11 +101,11 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 1. **注册位用 `SlotComponent

`(裸调用签名)而非 `FC

`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。 2. **`NoInfer` 把业务份额的推断钉在 inject 工厂上。** 没有它,TS 还会从组件形参位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默把 `I` 加宽到让调用通过——恰好吸收掉类型链本要抓的漂移。负样本 spec 钉住这一点:若这个 `NoInfer` 日后被「顺手简化」掉,expect-error 位会第一个变红。 -## Consequences +## 后果 -渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用;对 chain 坑,「谁来渲染」额外多出一层渲染期事实,但做决定的选择器全是 register 现场的声明,审计面仍是 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。 +渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用;对 chain slot,「谁来渲染」额外多出一层渲染期事实,但做决定的选择器全是 register 现场的声明,审计面仍是 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机制——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。 -## Alternatives considered +## 考虑过的替代方案 | Rejected | One-line reason | |---|---| @@ -115,5 +117,5 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | -| 接管坑用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 契约与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 | +| 接管 slot 用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 契约与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 | | 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 | diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index dfae9677c4..e1d9a10415 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: 4d0cbeff0c8a07362caa1ec18493267a9f0d2823 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 935a1a78a6bed451c1db646dec2ec5f4f5e87949 +2026-07-22-unified-send-and-coalesced-user-messages.md: f32d6ca65d5236e1fabdd177cdf54e36929c853f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 7647742166bb760139506f93af672f323b3b7b97 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 4d0cbeff0c..f32d6ca65d 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,23 +12,23 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. +**One primitive, three preset aliases.** The `Agent` interface's `send(message, target, wakeup)` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the remaining arguments own only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` reserves a driver when the agent is idle; an already active driver receives no second reservation and can claim the input only if it reaches a later pre-step boundary. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. -**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance. +**inject is a non-waking next-step delivery.** It always appends the complete message to the next-step inbox and records that insertion in a durable `agent/inbox/spliced` event. The driver claims it at a later pre-step and records it as model-visible `user/message` only when the final decision returns it in the entering batch; idle injection remains pending until another delivery wakes the driver. Its required `UserMessage.source` preserves the caller's explicit provenance. -**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. +**context/message is gone.** Injected context uses one `UserMessage` value in the inbox and becomes a `user/message` event if admitted; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. -**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree. +**Goal continuation attribution uses positive rounds.** Goal lifecycle state commits through the domain-owned `goal/change` event defined by the later [goal-owned durable event decision](2026-07-31-goal-owned-durable-events.md). A positive round advances only from an admitted continuation `user/message`; goal persistence does not use injection or inbox state. **`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. -**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending queued item was edited), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. +**Inbox mutations have one durable projection and three minimal live notifications.** Every append, prepend, edit, remove, cancellation, and claim records normalized `agent/inbox/spliced` coordinates. Insertions emit `agent/inbox/inserted { message }`; ordinary removals carry durable `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; the loop's atomic `claim()` records pure deletion splices and then emits `agent/inbox/claimed { message, turn }`. `MessageId` is the sole occurrence identity and remains unique across both pending lists. The live payloads deliberately omit placement, outcome, and batch envelopes because the durable splice owns those facts. -**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. +**Pre-step claims next-step input without making it a separate turn.** Steering and injection always enter the same next-step inbox; steering wakes the driver, while injection does not. At a turn boundary the driver atomically claims pending next-step input before one queued prompt, and between steps it claims only next-step input. Claiming records pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then rejects the proposed step or returns its complete entering batch. Rejection and listener failure leave the claimed batch removed; input arriving after the claim waits for a later boundary. -**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. A queued message that becomes steering keeps the same message value in the outbox, while injected and tool-produced context each carry their own identified message. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages. +**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. Steering, injection, and tool-produced context each keep their identified messages in the next-step inbox. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages. -**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second. +**Idle wakeup follows insertion.** A waking send inserts its input, then enters the running driver before returning. The first pre-step may claim that input immediately; later synchronous sends therefore join the running loop and wait for a later boundary. Cancellation belongs to the running turn signal from wakeup onward; no distinct pre-run phase intervenes. **cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). @@ -36,14 +36,14 @@ Separately, `context/message` and `user/message` had converged: the surface proj - **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly. - **A typed discriminant field on `UserMessage`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. -- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/inserted` is the live insertion signal, while claimed/discarded notifications describe exits and the durable splice retains placement. +- **Derive inbox placement from agent status.** Rejected because `running` includes pre-step processing and settlement. The producer already supplies the exact target to the durable splice. ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One identified message value serves prompts, injected context, and goal rounds, so every "human prompt?" check simplifies to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. Positive goal rounds fold from admitted `user/message` events, while goal lifecycle state remains outside the delivery surface. An idle injection remains pending without opening a turn or running the model, then becomes `user/message` when a later waking delivery's pre-step returns it in the entering batch. -`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. The later [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision adds live mutations over that occurrence identity without changing the one-message-per-turn or durable-message contracts. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to claim: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every insertion and exit publishes its matching live notification, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-message representation keeps durable splices and live events correlated without maintaining a second steering wrapper or allowing its data to diverge. The later [claimed pre-step inbox lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) decision keeps live queue mutations addressed by `MessageId` and separates single-message lifecycle notifications from the durable whole-queue splice projection. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 935a1a78a6..7647742166 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -8,27 +8,27 @@ Status: implemented agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 -另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都原样投影为 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 ## 决策 -**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 +**一个原语,三个预设别名。** `Agent` 接口的 `send(message, target, wakeup)` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;其余参数只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 会在 agent 空闲时保留一个驱动器;已经活跃的驱动器不会获得第二次保留,只有在抵达后续 pre-step 边界时才能领取该输入。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 -**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。 +**inject 是不会唤醒的 next-step 投递。** 它始终把完整消息追加到 next-step inbox,并在持久 `agent/inbox/spliced` 事件中记录该插入。驱动器会在后续 pre-step 领取它,并且只有最终决策把它放入进入步骤的批次时,才会将其记录为模型可见的 `user/message`;空闲注入会保持待处理,直到其他投递唤醒驱动器。必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。 -**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 +**context/message 已移除。** 注入的上下文在 inbox 中使用同一个 `UserMessage` 值,并在获准时成为 `user/message` 事件;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 -**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 +**Goal 继续执行归属使用正数 Round。** Goal 生命周期状态通过后续 [Goal 自有持久事件决策](2026-07-31-goal-owned-durable-events.md)定义的领域自有 `goal/change` 事件提交。正数 Round 只从已准入的继续执行 `user/message` 推进;goal 持久化不使用注入或 inbox 状态。 **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 -**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理的 queued 项被编辑)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 +**Inbox 变更只有一份持久投影和三种最小实时通知。** 每次 append、prepend、编辑、删除、取消与领取都会记录规范化的 `agent/inbox/spliced` 坐标。插入会发出 `agent/inbox/inserted { message }`;普通删除携带持久 `outcome: 'canceled'`,并发出 `agent/inbox/discarded { message }`;循环的原子 `claim()` 会记录纯删除 splice,随后发出 `agent/inbox/claimed { message, turn }`。`MessageId` 是唯一的单次出现标识,并在两个待处理列表间保持唯一。实时载荷刻意不携带 placement、outcome 或批次封套,因为这些事实由持久 splice 持有。 -**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 +**pre-step 会领取 next-step 输入,但不会为它单独创建轮次。** steering 和注入始终进入同一个 next-step inbox;steering 会唤醒驱动器,注入则不会。在轮次边界,驱动器会原子领取待处理的 next-step 输入,再领取一条排队提示词;在步骤之间则只领取 next-step 输入。领取会记录纯删除 splice,并针对每条消息发出一次 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 会拒绝拟议步骤,或返回进入步骤的完整批次。reject 与监听器失败都会让已领取批次保持已删除;领取后才到达的输入会等待后续边界。 -**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。一条成为 steering 的排队消息会在 outbox 中保留同一个消息值,而注入和工具产生的上下文则各自携带带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。 +**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。steering、注入和工具产生的上下文都会在 next-step inbox 中保留各自带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。 -**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。 +**空闲唤醒在插入之后发生。** 会唤醒的发送会先插入输入,再于返回前进入 running 驱动器。首次 pre-step 可能立即领取该输入;因此,后续同步发送会加入正在运行的循环,并等待更晚的边界。自唤醒开始,取消就归属于 running 轮次信号,中间不会插入独立的预运行 phase。 **cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 @@ -36,14 +36,14 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。 - **在 `UserMessage` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 -- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/inserted` 已经是实时插入信号,claimed/discarded 通知描述退出,而持久 splice 保留 placement。 +- **根据 agent 状态推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖 pre-step 处理与结算。生产方已经把精确目标写入持久 splice。 ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。同一个带标识消息值同时服务提示词、注入的上下文和 Goal Round,因此每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。正数 Goal Round 从已准入的 `user/message` 事件折叠,而 goal 生命周期状态位于投递接口之外。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。后续的[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 20745458d6..90d13fcfea 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-23-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e -2026-07-23-toolview-dissolution.zh.md: 311affcbd9605ff81b78e974f75ee83328d93f68 +2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32 +2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 406e5c181a..97d8beb4de 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. @@ -34,4 +34,4 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap ## Consequences -The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties. +The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 311affcbd9..db93c6252d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 @@ -34,4 +34,4 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘 ## Consequences -client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。 +client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml index e9adb2cd8b..7b1889d3de 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md 2026-07-24-adapter-owned-reasoning-effort-capabilities.md: cc66e4ec151fcc04445a91f4a3527cbddd130c33 -2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: e0d28e1aca370068478e8fb1704defeaac3ab351 +2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: d54fa6534bff03b38c9e372da264e5df41124351 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md index e0d28e1aca..d54fa6534b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -1,4 +1,4 @@ -# Agent Note:适配器持有的推理强度能力 +# Agent Note: 适配器持有的推理强度能力 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,主循环又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。 +推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,agent loop(智能体循环)又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。 ## 决策 -`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。由适配器持有的单次 `resolveModel(provider, model, signal?)` 查询返回 `LlmResolvedModelInfo`,其中包含确切模型身份以及可选的上下文和推理元数据。`LlmService.resolveModelInfo()` 会校验该聚合结果并返回分离值。`reasoning.efforts` 存在时,是包含展示元数据的非空有序 ID 列表,并可指定一个由配置确定的默认值。核心要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。 +`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。由适配器持有的单次 `resolveModel(provider, model, signal?)` 查询返回 `LlmResolvedModelInfo`,其中包含确切模型身份以及可选的上下文和推理元数据。`LlmService.resolveModelInfo()` 会校验该聚合结果,并返回与适配器内部状态分离的值副本。`reasoning.efforts` 存在时,是包含展示元数据的非空有序 ID 列表,并可指定一个由配置确定的默认值。核心要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。 -`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。 +`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop 在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的 agent loop 仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。 当部署策略允许思考时,原生 DeepSeek 适配器声明 `off`、`high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`。由适配器持有的 `off` 映射为 `thinking.type: disabled`,且不带 `reasoning_effort`;`high` 和 `max` 会启用思考并携带各自的官方协议强度值。配置为 `thinking: disabled` 的部署仅声明 `off`,并会在提供方 I/O 前拒绝启用思考的尝试。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。 @@ -20,7 +20,7 @@ Status: implemented **在核心中定义 pi-ai 的 `ThinkingLevel` 联合类型。** 不予采纳:pi-ai 当前的规范名称属于适配器实现细节;未来的提供方可以暴露不同的标识符,而无需为此发布新的核心版本。 -**携带无类型约束的提供方 options 对象。** 不予采纳:主循环既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。 +**携带无类型约束的提供方 options 对象。** 不予采纳:agent loop 既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。 **自动调整不支持的等级。** 不予采纳:静默替换会导致用户选定的控制项与日志记录的请求意图不一致,还会掩盖陈旧的部署配置。 @@ -30,4 +30,4 @@ Status: implemented 客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举或自行合成 `off`。适配器配置仍是部署默认值和策略的归属方,`agent/request` 则可以在该策略范围内为每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 -确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使主循环的资源释放达到完全停稳。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 +确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使 agent loop 的资源释放达到完全停稳。无密钥的服务、适配器、agent loop、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index 040701d1d6..c20fc92181 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md 2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2 -2026-07-24-project-session-directories.zh.md: 3d8d33fa9fddad010ab319ac4e1f873b69b4e1dd +2026-07-24-project-session-directories.zh.md: 932b1d29c41d2a854abfc0bab0e47a0ff8c96fe9 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index 3d8d33fa9f..932b1d29c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -23,9 +23,9 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。 -项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 +项目键有意不带哈希后缀。这遵循 coding agent(智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 -在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript(文本记录)时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。 +在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将已发现的路径和预期路径解析为同一个 transcript(文本记录)时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此,即使存在大小写别名,在区分大小写的存储上也不会放宽同一 id 的冲突检查。 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index 397258fe18..ae7f689398 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md -2026-07-24-separate-context-injection-from-turn-execution.md: bf3ae2ecbd2205a4c49e8004ffc694f89a2460a3 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: a805eb651c5c77f3d37c92dacd116bb41f154ed7 +2026-07-24-separate-context-injection-from-turn-execution.md: bb28d96cf1494d94ad7a7d4a4714e536c7072d2f +2026-07-24-separate-context-injection-from-turn-execution.zh.md: fa9b9275cae67f4d5a34dac8a0236558596ab28f diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md index bf3ae2ecbd..bb28d96cf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -18,29 +18,29 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu `inject()` is the only caller-facing operation for supplementary model-facing input, and a turn means one execution of the model loop. -`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `send()` or `steer()`. +A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `followup()` or `steer()`. -Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results. +An entering pre-step returns the complete `PreStepDecision.messages` batch for the request being finalized. Tool extension points still return `additionalContexts`, which enter the next-step inbox only after the corresponding tool results. These values are extension-point outputs, not attachments captured from a caller's inbox item. -Every additional context is an independent `user/message` whose `source` records provenance. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`. +Every additional context is an independent `UserMessage` whose `source` records provenance. Inbox insertion is durable immediately; admission later records the same value as `user/message`. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`. ## Injection lifecycle -During prompt admission or an open turn, `inject()` stages context in the loop outbox. The private next-step acceptance window opens before `agent/prompt-submit` and closes before `turn/end`, so steering and context accepted for one boundary reach the same following request while a `turn/end` listener's late steering becomes a queued prompt. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. +`inject()` always inserts context into the non-waking `next-step` inbox and commits that queue mutation as `agent/inbox/spliced`. A running driver claims it at the nearest later pre-step boundary. An idle driver leaves it pending until `followup()` or `steer()` supplies waking work; cancellation or disposal may discard it first without erasing the durable queue history. -Outside that window, `inject()` appends its `user/message` immediately. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model; persistence observes the append through `session/event`. +The loop claims the current next-step batch before running `agent/pre-step`, so an injection that arrives after that claim may miss the request already being finalized. The next boundary claims it instead. An enter decision appends its returned messages inside the owning turn before the request consumes them. Context produced during an assistant tool-call batch therefore appears after that batch's complete ordered results. -If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. +If pre-step rejects or throws, its claimed injected context, steering, and queued prompt stay removed and no returned batch is appended. Messages inserted after that atomic claim are unaffected and remain pending. -The session invariant permits `user/message` between turns while continuing to require turn enclosure for core execution events, steering, assistant output, and tools. Merge-extensible event relations belong to their declaring plugin rather than a core default. Persistence, recovery, resume, fork, and compaction treat valid between-turn events as committed session history rather than an interrupted or discardable turn tail. +The loop appends injected `user/message` events only from entered batches inside a turn. Core execution events, steering, assistant output, and tools remain turn-enclosed; merge-extensible event relations belong to their declaring plugin rather than a core default. ## Extension and caller semantics -`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. +The enter branch's `PreStepDecision.messages` is the complete batch for the proposed step. A waterfall listener that delegates with `next()` preserves downstream messages unless it intentionally replaces them; additions follow natural waterfall return order. Tool-result `additionalContexts` retain FIFO order and individual provenance. -Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `send(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper. +Caller-driven injection and current-step context deliberately use different timing. `inject()` joins the next pre-step available and cannot promise that a request already being finalized will consume it. A listener that must affect that exact request returns the context in `PreStepDecision.messages`; downstream rejection or failure then prevents it from materializing. -Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. +Cross-session references use that domain composition: TUI prepares the snapshot, returns it from the idle direct message's pre-step beside that message, or injects it before waking steering during a running turn. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md) and the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md). The later [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) applies the same execution-only meaning to plugin-owned records. @@ -48,27 +48,27 @@ This decision preserves the caller-owned framing decision from [unwrapped inject **Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. -**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. +**Keep a distinct `context/message` session event.** User-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. -**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. +**Keep one-shot turns for idle injection.** Durable inbox insertion already records idle context without opening a turn. A synthetic turn would make turn counts and observers report work that never ran the model; non-waking context remains pending until real waking work supplies a request. **Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. -**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. +**Let prompt hooks call `inject()` instead of returning messages.** An injection may miss the request whose prompt is already being finalized and would escape a downstream block of that decision. Returning the complete message batch keeps current-request context under the waterfall's authority. ## Verification -- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. +- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/inserted` reports only the inserted message, while the durable splice retains its target list. - `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers. - Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. -- Idle `inject()` appends one sourced `user/message` without a turn or model call. -- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them. -- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; caller context alone falls back to an idle append, while a steering boundary remains available to retry. -- Unit, persistence/resume, invariant, host/client queue, and TUI coverage pin event order, admission ownership, and reconnect classification. +- Idle `inject()` immediately appends one durable inbox insertion but no model-visible `user/message`; a later waking delivery may start pre-step processing. +- Active-turn injection is claimed at the nearest later pre-step boundary, after complete tool-result batches and before the request that consumes it. +- Rejected or failed pre-step drops its claimed batch; input inserted after the claim remains pending. +- Unit, persistence/resume, invariant, and TUI coverage pin event order, claim ownership, and durable replay. ## Consequences -- One surface event is valid outside turns, so persistence scanning, crash repair, forking, compaction, and session queries distinguish execution enclosure from session history. +- Idle injection is not model-visible until a later pre-step enters it and may be discarded by cancellation or disposal, while its durable inbox lifecycle remains recorded. - Consecutive user-role messages replace one baked prompt message; provider adapters preserve that ordering. -- Outside an acceptance window, `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership. +- Exact-current-request context must be returned from `agent/pre-step`; ordinary injection provides only nearest-later-boundary delivery. - The public delivery contract and inbox records remain small: no context attachment, context-placement metadata, prompt envelope, or duplicate durable event type. diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md index a805eb651c..fa9b9275ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -18,57 +18,57 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: `inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。 -`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `send()` 或 `steer()` 提交直接消息。 +拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `followup()` 或 `steer()` 提交直接消息。 -提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。 +返回 enter 的 pre-step 会为正在最终确定的请求返回完整的 `PreStepDecision.messages` 批次。工具扩展点仍可返回 `additionalContexts`,这些上下文只会在对应工具结果之后进入 next-step inbox。这些值是扩展点的输出,而不是从调用方 inbox 条目捕获的附件。 -每项额外上下文都是独立的 `user/message`,并由 `source` 记录来源。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。 +每项额外上下文都是独立的 `UserMessage`,并由 `source` 记录来源。inbox 插入会立即持久化;后续准入会将同一个值记录为 `user/message`。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。 ## 注入生命周期 -提示词准入期间或轮次打开时,`inject()` 会将上下文暂存在 loop outbox 中。私有的 next-step 接受窗口在 `agent/prompt-submit` 前打开,并在 `turn/end` 前关闭,因此同一边界接受的 steering 和上下文会进入后续同一次请求,而 `turn/end` 监听器提交的晚到 steering 则成为排队提示词。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接受的上下文,只能出现在该批次所有有序结果之后。 +`inject()` 始终把上下文插入不会唤醒的 `next-step` inbox,并以 `agent/inbox/spliced` 提交该队列变更。运行中的驱动器会在最近的后续 pre-step 边界领取它。idle 驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 提供可唤醒工作;在此之前,取消或 dispose(资源释放)可能将其丢弃,但不会抹除持久队列历史。 -在该窗口之外,`inject()` 会立即追加对应的 `user/message`。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型;持久化通过 `session/event` 观察这次追加。 +循环会先领取当前 next-step 批次,再运行 `agent/pre-step`,因此领取后到达的注入可能赶不上正在最终确定的请求,而由下一次边界领取。enter decision 返回的消息会在所属轮次内、消费它们的请求之前追加。在助手工具调用批次期间产生的上下文因此只会出现在该批次全部有序结果之后。 -如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 +如果 pre-step reject 或抛错,其已领取的注入上下文、steering 与排队提示词都会保持已删除,也不会追加返回批次。原子领取后插入的消息不受影响,继续保持待处理。 -会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求核心执行事件、steering、助手输出和工具事件均受轮次边界约束。可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。持久化、恢复、resume、fork 和压缩会把合法的轮次间事件当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 +loop 只会在轮次内从进入步骤的批次追加注入的 `user/message`。核心执行事件、steering、助手输出和工具事件仍受轮次边界约束;可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。 ## 扩展点与调用方语义 -`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 +enter 分支的 `PreStepDecision.messages` 是拟议步骤的完整批次。waterfall(瀑布式事件)监听器调用 `next()` 委托时,会保留下游消息,除非有意替换;新增消息遵循 waterfall 的自然返回顺序。工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源。 -调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 +调用方主动注入与当前步骤上下文刻意采用不同的时序。`inject()` 会加入下一个可用 pre-step,无法保证正在最终确定的请求会消费它。必须影响该请求的监听器在 `PreStepDecision.messages` 中返回上下文;下游 reject 或失败时,该上下文不会落入日志。 -跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 +跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在 idle 直接消息的 pre-step 中把快照与该消息一同返回,或在 running 轮次中先注入快照再唤醒 steering。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 -本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。 +本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的由调用方决定内容框架的原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。 ## 曾考虑的替代方案 **保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 -**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 +**保留独立的 `context/message` 会话事件。** 面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 -**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 +**为空闲注入保留一次性轮次。** 持久 inbox 插入已经能在不打开轮次的情况下记录空闲上下文。合成轮次会让轮次计数与观察方报告从未运行模型的工作;不会唤醒的上下文会保持待处理,直至真实的可唤醒工作提供请求。 **保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 -**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 +**让提示词钩子调用 `inject()`,而不是返回消息。** 注入可能赶不上提示词正在最终确定的请求,也会逃逸下游对该 decision 的阻止。返回完整消息批次能让当前请求上下文继续受 waterfall 约束。 ## 验证 -- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 +- 投递输入与 steering inbox 记录不包含附加上下文;`agent/inbox/inserted` 只报告插入消息,目标列表由持久 splice 保留。 - `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。 - 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 -- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。 -- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 -- 被阻止的提示词准入不会打开轮次,也不会追加提示词或钩子产生的额外上下文;仅有调用方上下文时会回退为空闲追加,而带 steering 的边界仍可重试。 -- 单元测试、持久化与 resume 测试、不变量测试、宿主/客户端队列测试和 TUI 覆盖会固定事件顺序、准入归属和重连分类。 +- idle 状态下的 `inject()` 会立即追加一条持久 inbox 插入记录,但不会追加模型可见的 `user/message`;后续可唤醒投递可能开始 pre-step 处理。 +- 活跃轮次中的注入会在最近的后续 pre-step 边界领取,并位于完整工具结果批次之后、消费它的请求之前。 +- pre-step reject 或失败会丢弃其已领取批次;领取后插入的 inbox 工作继续保持待处理。 +- 单元测试、持久化与 resume 测试、不变量测试和 TUI 覆盖会固定事件顺序、领取归属和持久回放。 ## 后果 -- 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。 +- idle 注入要到后续 pre-step 让它进入步骤后才会对模型可见,并可能被取消或 dispose 丢弃,而其持久 inbox 生命周期仍会保留记录。 - 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。 -- 在接受窗口之外,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 +- 必须影响当前请求的上下文要从 `agent/pre-step` 返回;普通注入只保证由最近的后续边界交付。 - 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index 45e1c99967..19176b7654 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-24-single-harness-home-resolver.md 2026-07-24-single-harness-home-resolver.md: 159ba88b7b4a8d50f1be2cbe5d9162a654014e16 -2026-07-24-single-harness-home-resolver.zh.md: 62046abca48a3c2b07fde4180031dc2186dc101f +2026-07-24-single-harness-home-resolver.zh.md: 89069c8e98e80be387fbc6b1630219b451be7bd6 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 62046abca4..89069c8e98 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -1,4 +1,4 @@ -# Agent Note:单一 harness home 解析器 +# Agent Note: 单一 harness home 解析器 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index d50428d5ed..2c1f309a79 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md 2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 5f03dfbb8e5eaeeb52076584721e70ea66a292df diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index ea2a8f70a6..5f03dfbb8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -1,4 +1,4 @@ -# Agent Note:dsh web 的 config-tree boot 与 web 传输分层 +# Agent Note: dsh web 的 config-tree boot 与 web 传输分层 Status: implemented @@ -8,19 +8,19 @@ Status: implemented ## 问题 -`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE 通道、硬编码 `/api/*` 前缀;dev 的 bundle watch 寄居在 prod registry 里靠 `watch?` 参数开关、生命周期无主;图 registry 对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退进程的 sink。还有一个用户可见缺陷:web 路径不装 `$DSH_HOME/.env`,`DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。 +`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE(Server-Sent Events)通道、硬编码 `/api/*` 前缀;dev 的 bundle watch 寄居在 prod 注册表里靠 `watch?` 参数开关、生命周期无主;图注册表对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退出进程的 sink。还有一个用户可见缺陷:web 路径从不加载 `$DSH_HOME/.env`,`DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。 ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 -**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 +**boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 +**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI(命令行界面)flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 -**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 +**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从已退役的运行时包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志,不退出进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包括否定结论在内的包元数据会永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。HMR node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 -**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。 +**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 @@ -28,12 +28,12 @@ Status: implemented - headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 -## Alternatives considered +## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| | 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config | -| runtime 里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住 runtime;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | +| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | | dev overlay / `cordis.dev.yml` | 一套 yml;`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 6b8c6de32d..37eccb0c95 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1f88dd2065eaba7282ae3ed9e82d6872fdfb8497 +2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index d19b256b83..aeefbe22a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-25-web-client-session-scope-and-provide-channel.zh.md) -> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), the read-only queue mirror (`session/queued`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). +> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). ## Problem @@ -64,17 +64,17 @@ A session "materialized but with no first prompt" is governed by the summary-der - The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility. + - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally. -- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. +- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. ### connectWorkspace: the sole entry point of New Session `workspaces.connectWorkspace(workspaceId): Promise` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference): -- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing. +- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing. - The create arm: on a miss, `session.create({workspaceId})` returns the new id. - An unknown workspaceId fails loud (never silently creating somewhere else). - The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush. @@ -101,7 +101,6 @@ Slot scope is the closed set `root | session-maybe | session`: ### The read-only queue mirror -- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match). The host stamps the agent-loop's acceptance-time steering classification on live and replayed frames, so a reconnect baseline does not depend on replaying an earlier `turn/start`. Queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. ### Host wire smalls diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 1f88dd2065..056d50d45c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -4,18 +4,18 @@ Status: implemented [English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文 -> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`)、队列只读镜像(`session/queued`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 +> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 ## 问题 -web client 只有一张全局会话面:slot 全部从根 context 渲染,插件拿不到「当前是哪个 agent/session」的语境;draft 真身埋在 Session 对象里,任何要参与输入的插件都无处下手。要支撑命令/输入体系,平台层必须先回答: +web client 只有一张全局会话面:slot 全部从根上下文渲染,插件拿不到「当前是哪个 agent/会话」的语境;draft 的权威副本埋在 Session 对象里,任何要参与输入的插件都无处下手。要支撑命令/输入体系,平台层必须先回答: - 会话交互态(菜单、popup、草稿、在途请求)归谁持有,双会话如何结构性隔离; -- 「新会话」在 host 实体存在之前是什么——client 要不要为它造一段独立生命; -- session-scope 组件如何「自己拿会话数据」,而不是层层下传 props; -- 用户放弃的新会话在 host 侧留下什么,谁来收。 +- 「新会话」在 host 实体存在之前是什么——client 是否必须为它凭空创建独立生命周期; +- 会话 scope 组件如何「自己拿会话数据」,而不是层层下传 props; +- 用户放弃的新会话在 host 侧留下什么,由谁回收。 -硬约束:host 是唯一真源;一切注册走 `ctx.effect` disposer;scope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入 session log。 +硬约束:host 是唯一真源;一切注册走 `ctx.effect` disposer;scope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入会话日志。 ## 决策 @@ -26,13 +26,13 @@ host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(原 - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 - 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 - 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何 session/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 -- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill/…)它与任何会话无异,slash/plan 天然全活。 +- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 -runtime `agents/scope.ts` 与 host `dsh-scope` 机制层一致(fiber + tag + filter 过滤;不 value-import:host 包携带 scoped-events 的 `Events` merge,进 client program 撞 Context merge): +运行时 `agents/scope.ts` 与 host `dsh-scope` 机制层一致(fiber + tag + filter 过滤;不 value-import:host 包携带 scoped-events 的 `Events` merge,进 client program 撞 Context merge): -- `createScope(ctx, key)`:no-op plugin fiber + `extend({[kScope]: key, [Context.filter]: …})`——filter 直接住 actx:untagged listener 全局可收,tagged 只收本 scope。 +- `createScope(ctx, key)`:no-op 插件 fiber + `extend({[kScope]: key, [Context.filter]: …})`——filter 直接住 actx:untagged listener 全局可收,tagged 只收本 scope。 - 派发就是 cordis 原语,thisArg = actx 本身:`actx.bail(actx, event, req)` / `actx.emit(actx, event, payload)`。 - `Session.bindScope(actx)`:resolve 铸 scope 时单次配对(重复绑 throw;dropScope unbind),镜像 host `Agent.loopCtx`——Session 用它自行派发 scoped 事件。actx→Session 反向走 `sessions.sessionOf(actx)` 一跳(镜像 host 插件 `agent.session` 用法)。 @@ -40,9 +40,9 @@ runtime `agents/scope.ts` 与 host `dsh-scope` 机制层一致(fiber + tag + f - filter 住 actx 自身而非独立 carrier:host 包装层护的是「业务 Agent subject 与 scope key 不漂移」(host 事件首参注入 Agent 本体),client 事件 payload 只带 id、无 subject 可护。 - key 用品牌 `SessionId` 值比较而非对象身份:host 里 agent.id === session id(1:1 同轴),agent 身份直接复用 `SessionId` 品牌,client scope 的身份即 wire id。 -- client 是 **Agent 身份** scope 而非活对象 scope:cold 会话期 host Agent 对象已 dispose 而 client actx 存活(视野内)——身份轴严格对等、对象冷热有意不同步。 +- client 是 **Agent 身份** scope 而非活对象 scope:cold 会话期 host Agent 对象已 dispose(资源释放)而 client actx 存活(视野内)——身份轴严格对等、对象冷热有意不同步。 -id→ctx 换乘只许三类位置(业务 provider 永不换乘): +id→ctx 换乘只许三类位置(业务提供方永不换乘): - slot inject 工厂:ctx 不进渲染层,slot 框架交给组件的身份就是 sessionId,经服务 map 换回对象/controller。 - root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。 @@ -53,8 +53,8 @@ id→ctx 换乘只许三类位置(业务 provider 永不换乘): Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用): - 出生 = 会话行进入 client 视野(list 基线拉取 / `create()` 本地回声 / `host/session-added` 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。 -- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费者)、session-keyed slot store。staged session(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。 -- 重开 = lazy 重建实例 + `open()` 拉 history(host session log 是持久真相)。 +- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费方)、会话键控 slot store。暂存会话(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。 +- 重开 = lazy 重建实例 + `open()` 拉 history(host 会话日志是持久真相)。 - 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。 ### blank 位:空会话的可见投影、转正与复用 @@ -64,61 +64,60 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 - host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。 + - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。 -- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 +- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 ### connectWorkspace:New Session 的唯一入口 `workspaces.connectWorkspace(workspaceId): Promise`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用): -- 复用臂:list mirror 中找 `blank && cwd == workspace.path`(host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。 +- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。 - 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。 - 未知 workspaceId fail loud(不静默创建到别处)。 - 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。 - 调用方拿 id 自行 `sessions.open`;首讯发送就是普通 `session.prompt`——会话本来就在,失败即普通 prompt 失败,draft 文本还在 machine 里,重试即再次发送。 -- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无 session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。 -- runtime 启动时订阅首次完整基线:若已有恢复成功的 current session 则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank session。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。 +- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无会话视图。Workspace 分组内的创建动作仍显式命中该 Workspace。 +- 运行时启动时订阅首次完整基线:若已有恢复成功的 current 会话则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank 会话。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。 - blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 `sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。 -### per-session 供数:`sessions.provide` 标准件通道 +### 逐会话供数:`sessions.provide` 标准件通道 -session slot 组件「自己拿 session 数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定 session 下物化值并随 scope 拆;web-react `standardKit` 统一循环把 hooks 格绑成 `use` 选择器 hook(`observableHook`→uSES,防 tearing)、props 格原样透传。 +会话 slot 组件「自己拿会话数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定会话下物化值并随 scope 拆;web-react `standardKit` 统一循环把 hooks 格绑成 `use` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。 slot scope 是闭集 `root | session-maybe | session`: - `root` 只拿全局标准件,不接收 session 身份或供数。 -- `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 -- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 +- `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 +- `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 -- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。 +- 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。 ### 队列只读镜像 -- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 `turn/start`。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 ### host wire 小件 - summary `blank` 列与 `host/session-added` 帧 `blank` 字段(见上文 blank 位)。 -- SSE 帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为 stale)。 -- `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的 resume 语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 +- SSE(Server-Sent Events)帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为陈旧)。 +- `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 - `session.create` 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。 -## Alternatives considered +## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| | client-local Intent + materialize(published CAS / pendingPrompt attach 事务 / before-create 链) | client 被迫模拟 host 缺失的前半段生命,养出 published CAS、attach 事务、部分发布一坨状态机 | | host 预留 ID(draft Map) | host 只认了个号,状态机原封留在 client | -| host draft Session(有 Session 无 Agent) | 每个查 Agent 的 host 面都要为 draft 分叉;core 要开 attachAgent 缝 + header cwd 后写 | -| 无 cwd 先绑 Agent(ungrouped) | header.cwd readonly "created in" 不变性被推翻 + launch-dir 副作用产品坑 | +| host draft Session(有 Session 无 Agent) | 每个查 Agent 的 host 面都要为 draft 分叉;core 要开 attachAgent seam + header cwd 后写 | +| 无 cwd 先绑 Agent(ungrouped) | header.cwd readonly「created in」不变性被推翻 + launch-dir 副作用产品坑 | | React Context 层层传会话语境 | 插件在 host/client 两侧应是一个心智模型;scope 机制与 host dsh-scope 同构 | | `scopeTarget` carrier + 融合派发器(镜像 host `agentEvents`) | host 包装层护的是「业务 Agent subject 与 scope key 不漂移」,client 事件无 subject 可护;filter 住 actx + cordis 原语覆盖全部需求 | | Session 不持 ctx(对象层 cordis-free) | 只为筛选单测不引 cordis 而生的红线,代价是 contribute 两跳回调 + 可变公有字段;host Agent 本就持 loopCtx | @@ -130,8 +129,8 @@ slot scope 是闭集 `root | session-maybe | session`: ## 后果 -- 插件获得与 host 同构的会话语境:per-session 状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 -- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等 provider 一律以 sessionId 直接寻址。 +- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 +- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有契约(重启蒸发),常规上限靠同 Workspace 复用。 - 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,等独立分支)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index 29f56666b2..1742b6587e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -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-25-web-command-surfaces-and-assembly.md 2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c -2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b +2026-07-25-web-command-surfaces-and-assembly.zh.md: 092401751aa16c1a0ec4446d85a9f2591ee18425 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index c0acd1ecc5..092401751a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -4,31 +4,31 @@ Status: implemented [English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文 -> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。 +> 范围:命令目录缓存与三型派发(ui-command)、popup 选择流、skill(技能) / subagent 两个引用源、fixture(测试前置数据)命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。 ## 问题 -管线就绪但没有命令知识的落点:host 侧 `ctx.commands` 与 `ctx.skills` 完整而 web 通道无命令能力。业务层要回答: +流水线就绪但没有命令知识的落点:host 侧 `ctx.commands` 与 `ctx.skills` 完整而 web 通道无命令能力。业务层要回答: - 命令 UI 不止一种形态(当场执行、弹选择框、回填后继续打参数)——业务包如何零骨架改动上架; - 目录何时拉取:每次开菜单现拉太慢,常驻缓存就要有失效与重连故事; -- 会话恒 agent-backed(Session+Agent 同瞬出生),client 命令面以什么地址兑现 host 的 per-agent 有效目录; +- 会话恒 agent-backed(Session+Agent 同瞬出生),client 命令面通过什么地址访问 host 的逐 agent 有效目录; - 装配级验收:拆开的各层合起来,用户可见主链如何钉住。 ## 决策 -### ui-command:`CommandService` + session 键控 `CommandDirectory` + per-session `PopupSelectController` +### ui-command:`CommandService` + 按会话键控的 `CommandDirectory` + 逐会话 `PopupSelectController` - 投影 `ClientSessionContext { sessionId }` 自持于 ui-slash 契约(types.ts):会话恒 agent-backed,会话身份即命令能力的全部投影;wire 以 `{sessionId}` 寻址(`command.list` / `command.execute` 均是;host 从会话 header 解析 Agent)。 -- 目录按 `SessionId` 分格,per-key single-flight + epoch guard(旧拉取永不覆盖新态),`commands/changed` 全 key 软失效(旧快照继续服务、后台重拉)、`connection/reset` 全 key 硬失效并预热,Enter 强等当前 key、失败留草稿不降级。预热挂 source 的 `warm` 钩子——scope 出生时对全 roster 一次,即覆盖整个会话生命周期(会话能力自出生恒定)。 +- 目录按 `SessionId` 分区,per-key single-flight + epoch guard(旧拉取永不覆盖新态),`commands/changed` 全 key 软失效(旧快照继续服务、后台重拉)、`connection/reset` 全 key 硬失效并预热,Enter 必须等待当前 key 就绪、失败留草稿不降级。预热挂 source 的 `warm` 钩子——scope 出生时对全 roster 一次,即覆盖整个会话生命周期(会话能力自出生恒定)。 - `register(contribution)` 注册 client 命令(descriptor + `available(projection)` + popupSelect spec);候选合成 = host 目录 + contribution 可用性过滤,再过 query/position,host/contribution 重名 fail loud。 - 命令三型按注册面派生,开发者不声明位置:host descriptor 带 `input` = **leadingInput**(回填 `/name ␣` + claim,继续打参数,仅限行首);client 注册 popupSelect spec = **popupSelect**(官方选择框壳,业务零组件);两者皆无 = **execute**(选中即执行,零 UI)。 -- 判定决策表:菜单可触发三型;Space 只认 leadingInput(误触发防线:不可逆副作用只留显式入口);Enter 裸 token 才 execute/开壳、leadingInput 容忍尾随参数。 -- `popupFor(actx)` 的 popup:search 本地过滤、select single-flight、open 时捕获投影、onSelect 成功才经 consume-token 事件消 token、失败保留可重试、session 切换只隐藏。popup 壳是瞬态层(不进状态机):框持焦点、Enter/↑↓/Escape 归它、点框外即 dismiss(点 textarea 同时归还焦点)。 +- 派发决策表:菜单可触发三型;Space 只认 leadingInput(误触发防线:不可逆副作用只留显式入口);Enter 裸 token 才 execute/开壳、leadingInput 容忍尾随参数。 +- `popupFor(actx)` 的 popup:search 本地过滤、select single-flight、open 时捕获投影、onSelect 成功才经 consume-token 事件消 token、失败保留可重试、会话切换只隐藏。popup 壳是瞬态层(不进状态机):框持焦点、Enter/↑↓/Escape 归它、点框外即 dismiss(点 textarea 同时归还焦点)。 ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 +- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通提示词走(命令平面之外;tool-skill 不变,会话前缀目录提供协作关联)。 - **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 @@ -38,20 +38,20 @@ Status: implemented ### 装配级验收:slash-flow 快照 -`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 +`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配后的 transcript(文本记录)):无会话时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank 会话 → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通提示词成功受理后同一行转正;同一个会话绑定的 textarea 在 blank → active 转换期间保持不变。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 ## Alternatives considered | 弃案 | 一行理由 | |---|---| -| prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 | +| 提示词内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 | | skill 物化为 command 的桥 | skill 自有目录;N 笔注册是绕路;标签形式天然避开命令平面 | -| `skill.invoke` RPC | host 无此操作;skill 引用是随 prompt 的普通文本 | -| 新 ContentBlock 引用类型 | 全链路成本(adapter/UI/compaction);文本即真身 + 结构化 occurrence 记录已足够 | +| `skill.invoke` RPC | host 无此操作;skill 引用是随提示词的普通文本 | +| 新 ContentBlock 引用类型 | 全链路成本(适配器/UI/压缩(compaction));文本即真身 + 结构化 occurrence 记录已足够 | | client 各包自报命令目录 | host 是唯一真源;client 只读 descriptor,`commands-changed` 推失效 | | `requires: 'none' \| 'agent'` 判别轴(agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner;整轴回退 master 形状,待真需求重开 | -| 专用 commandresult / commandpanel 坑位 | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 | -| agent-type 目录做 `@` 源 | 无类型注册表;live-session 快照已覆盖 | +| 专用 commandresult / commandpanel slot | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 | +| agent-type 目录做 `@` 源 | 无类型注册表;实时会话快照已覆盖 | | PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 | ## 后果 @@ -59,4 +59,4 @@ Status: implemented - 业务命令上架 = host 注册 + client 一笔 `command.register`(popupSelect)或零注册(execute/leadingInput 自动派生),零骨架改动;代价是三型语义集中在 ui-command,假想的第四型意味着改它。 - 常驻目录缓存 + 推失效换来菜单零延迟与回车裁决可靠;代价是三条失效路径(change 帧、重连、epoch guard)都需测试钉住。 - sessionId 寻址让 host 的 per-agent 有效目录(全局 + scoped shadows)直接上 wire,client 原样呈现。 -- 已知欠账:popupSelect 壳暂无已上架业务消费者(模型选择等 #600 的 host `selectModel` 以 live-mutation 形态回归,届时作接入样板);队列第二刀(逐项 Inbox 操作)、富结果卡、roster 可配置性入台账待触发。 +- 已知欠账:popupSelect 壳暂无已上架业务消费方(模型选择等将随 #600 的 host `selectModel` 以 live-mutation 形态回归,届时作接入样板);队列第二刀(逐项 Inbox 操作)、富结果卡、roster 可配置性入台账待触发。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 52c7c71e1a..a52995c855 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md 2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 929a885bf54a31605805814ba1e15c901e560434 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 929a885bf5..f70065c8b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Web 输入状态机、composer 坑位与 slash 管线(ui-conversation input / ui-slash) +# Agent Note: Web 输入状态机、composer slot 与 slash 管线(ui-conversation input / ui-slash) Status: implemented [English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文 -> 范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单管线(ui-slash)、composer 周边坑位体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)的领地。 +> 范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单管线(ui-slash)、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)的领地。 ## 问题 @@ -91,12 +91,12 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 - ui-conversation(hub 兼贡献者)经 `sessions.provide` 供 `'input'` hook(机器状态 + queue overlay)+ `inputActions` prop(`setDraft`/`submit`,稳定 void 回调)。 - 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。 -### 坑位体系 +### slot 体系 -`conversation` 本身是 session-maybe;其会话内容与 composer 输入坑位严格 session,Hero Workspace picker 保持 root。子坑均由 ui-conversation 的 conversation 注册声明: +`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: - `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。 -- `conversation.composer.bar`(single)——InputBar 本体的坑位:InputBar 是真 slot entry(自家坑自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 +- `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 @@ -121,7 +121,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 | | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | -| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 占位 select 常驻工具行 | 具名 slot 在注册前保持为空;占位件与真实现冲突时是双真相源 | | 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome | | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml index 66ff10e180..881981631c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md 2026-07-26-packed-chunk-rows-by-default.md: d6a044676604e4a4512a7a6674edb80e120b2f3c -2026-07-26-packed-chunk-rows-by-default.zh.md: 184d462d70dcc666a0b38497ead307ce6861382d +2026-07-26-packed-chunk-rows-by-default.zh.md: 2488ab1d972b89b1248733482e8e4239b83797e2 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md index 184d462d70..2488ab1d97 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md @@ -12,7 +12,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封 ## 决策 -`dsh-session-persistence-jsonl` 会将省略的 `packChunks` 解析为 `true`。ACP(Agent Client Protocol)演示包装层公开相同的默认值,所有省略该字段的组合都会继承打包写入。`packChunks: false` 仍是写入侧显式诊断模式,以每事件一行的形式存储。 +`dsh-session-persistence-jsonl` 会将省略的 `packChunks` 解析为 `true`。ACP(Agent Client Protocol)演示包装层公开相同的默认值,所有省略该字段的组合都会继承打包写入。`packChunks: false` 仍是写入侧显式诊断模式,以每个事件一行的形式存储。 读取始终不受选项控制且与布局无关。打包、非打包和混合文件都会加载为相同且连续的 `SessionEvent[]`,因此更改默认值不需要变更会话格式版本,也不需要对磁盘数据执行运行时迁移。该选项只控制新追加的批次,绝不会选择读取器模式。 @@ -28,7 +28,7 @@ JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none ACP 和 headless 快照运行会采集默认 JSONL 后端的输出。TUI 和 web 的记录模式写入器会在写入 fixture 前,对内存事件应用 `packChunkRuns()`。人工编写的 `packed-chunks` ACP 场景在普通配置下运行,并保留全部 3 种打包行类型;其契约先解码独立的源 fixture 和目标 fixture,再断言二者逐事件相等。 -聚焦的包(package)测试保留非打包和混合布局输入,以验证读取器兼容性。这些测试不会让默认快照语料库豁免规范布局要求。 +聚焦的包测试保留非打包和混合布局输入,以验证读取器兼容性。这些测试不会让默认快照语料库豁免规范布局要求。 ### 在途分支收敛 @@ -38,7 +38,7 @@ ACP 和 headless 快照运行会采集默认 JSONL 后端的输出。TUI 和 web ### 验证契约 -JSONL 持久化测试证明:省略选项时会写入打包行,显式传入 `false` 时会按每事件一行的形式写入,两种形式都会加载为完全相同的事件。规范布局转换器单元测试覆盖 header 保留、非打包转换、非会话 JSONL、已打包输入的幂等性和畸形输入。无密钥快照门禁覆盖每个签入仓库的 fixture 和组装后的回放路径;文档门禁则确保配置默认值与双语契约保持一致。 +JSONL 持久化测试证明:省略选项时会写入打包行,显式传入 `false` 时会按每个事件一行的形式写入,两种形式都会加载为完全相同的事件。规范布局转换器单元测试覆盖 header 保留、非打包转换、非会话 JSONL、已打包输入的幂等性和畸形输入。无密钥快照门禁覆盖每个签入仓库的 fixture 和组装后的回放路径;文档门禁则确保配置默认值与双语契约保持一致。 ## 曾考虑的替代方案 @@ -46,7 +46,7 @@ JSONL 持久化测试证明:省略选项时会写入打包行,显式传入 ` **快照继续使用非打包格式以便阅读。** 打包行仍会显式保留每个片段和时间戳,共享解码器与规范化器则提供逻辑检查。如果让规模最大的签入仓库消费方采用不同布局,快照覆盖就会绕开已交付的写入路径。 -**删除 `packChunks` 并始终打包。** 只保留一个写入器更简单,但每事件一行的输出仍适用于诊断和聚焦的混合布局兼容性测试。显式停用选项在不削弱默认值的同时,保留了这些现有消费方。 +**删除 `packChunks` 并始终打包。** 只保留一个写入器更简单,但每个事件一行的输出仍适用于诊断和聚焦的混合布局兼容性测试。显式停用选项在不削弱默认值的同时,保留了这些现有消费方。 **把分片批量合并为逻辑会话事件。** 这会减少事件数量,但也会延迟或重塑实时传递,改变溯源信息所引用的序号,并要求每个 UI 和回放消费方理解另一种流式单位。物理打包通过现有持久化接口获得存储收益。 @@ -54,6 +54,6 @@ JSONL 持久化测试证明:省略选项时会写入打包行,显式传入 ` ## 后果 -常规 JSONL 写入与签入仓库的 fixture 使用更少的物理行,同时精确保留逻辑事件流。运行时读取器接受所有现有布局,操作方也保留有意提供的非打包诊断模式。按 token 逐行处理原始文件较为不便;错误地将 header 后每一行都视为 `SessionEvent` 的外部工具会更频繁地遇到存储 tag,受支持的读取器则会调用 `decodeStorageRecord()`。 +常规 JSONL 写入与签入仓库的 fixture 使用更少的物理行,同时精确保留逻辑事件流。运行时读取器接受所有现有布局,操作方也保留显式的非打包诊断模式。按 token 逐行处理原始文件较为不便;错误地将 header 后每一行都视为 `SessionEvent` 的外部工具会更频繁地遇到存储标签,受支持的读取器则会调用 `decodeStorageRecord()`。 仓库会产生大规模机械 fixture diff;评审应依据解码结果相等这一事实和规范布局门禁,而不是逐行、逐 token 检查。仓库还会暂时保留一个分支迁移命令及其链接;单独的移除提案会防止这项过渡辅助机制成为永久的流程接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 5e423f9383..daa727ccb0 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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-26-subprocess-consumer-migration.md 2026-07-26-subprocess-consumer-migration.md: 477dffc2271db08b8986b34645067b247ad7ace7 -2026-07-26-subprocess-consumer-migration.zh.md: 5e1035872c6e2101e41d2801cccdfb5ef2c088fc +2026-07-26-subprocess-consumer-migration.zh.md: 8e0e377fc3fe00ff452803fe7fe5f4115003f937 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 5e1035872c..8e0e377fc3 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 +# Agent Note: 子进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。 +[子进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。 ## 决策 @@ -29,10 +29,10 @@ Status: implemented **把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 -**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。 +**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。 ## 后果 换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、生命周期接口面从一个动词扩展为 terminate/waitForExit/dispose 这一组),未来的后端因此要实现更宽的接口面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 6c6b1d6b44..1a918fc62c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md 2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f -2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4 +2026-07-26-subprocess-seam.zh.md: 575c02e346531824ef409ddc6155aa2b59ce4e63 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index d9a0fb56b5..575c02e346 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) +# Agent Note: 子进程服务是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于同级的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 ## 决策 @@ -19,7 +19,7 @@ Status: implemented 如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 -后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 +后台进程的存续期从执行器移到了子进程服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,服务会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 ## 曾考虑的替代方案 @@ -29,10 +29,10 @@ Status: implemented **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 -**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入子进程服务。**否决:通用子进程服务不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 ## 后果 -换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与服务生命周期/dispose 套件);执行器测试套件如今以真实服务为基准,固定 bash 自有的各层行为(分类、合并、spawn 失败提示、归服务所有的存续期)。 -代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载子进程服务,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 0187c1ff47..9b9fd5aad6 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md 2026-07-26-task-registry-seam.md: 57ac176cf6d2b0a50fcbcfacd77f6a26b462b582 -2026-07-26-task-registry-seam.zh.md: 252382ac39ebf1e5077fad87fcee2537ae8a9ab3 +2026-07-26-task-registry-seam.zh.md: d32de0cc62e1d7a2b5e5d70187e742aa0392fe29 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 252382ac39..d32de0cc62 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 +[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包:`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented - **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 - **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。 -各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即定义缺失的 `ctx.tasks` 服务的 seam 包;seam 自身的表面(其 README 与直接挂载防线)会指向各实现,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 +各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即定义缺失的 `ctx.tasks` 服务的 seam 包;seam 自身的对外呈现(其 README 与直接挂载防线)会指向各实现,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此直接挂载 seam 时,其构造函数会明确报错——一条陈旧的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个未完整注册的 `ctx.tasks` 在远离错误配置处才失败。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index bd5964f1a4..70d44e1767 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md 2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 -2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a +2026-07-27-dispose-ladder-to-consumer.zh.md: 7fff744e64109549a65d4f5bb17ff2d6ddfc6888 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index b6849ad393..7fff744e64 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -6,18 +6,18 @@ Status: implemented ## 问题 -`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP(Agent Client Protocol)subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的完全停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 -**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号才能完全停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 **把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 ## 后果 -买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 +换来的是:seam 少了一个方法和一个类型;实现只需提供四个动词,无需提供拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index c15af141bd..0e2d5229a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-28-api-browser-trust-boundary.md 2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0 -2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee +2026-07-28-api-browser-trust-boundary.zh.md: 36a8323868f01f4cf16107ae62b649ade436482b diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 2958f7e49b..36a8323868 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -1,21 +1,21 @@ -# Agent Note:整个 /api 面共用一道载体级浏览器信任边界 +# Agent Note: 整个 /api 接口共用一道载体级浏览器信任边界 -状态:已实现 +Status: implemented [English](2026-07-28-api-browser-trust-boundary.md) | 中文 ## 问题 -Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以"同源"身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正要命的方法都在裸奔。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 +Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以「同源」身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正具有严重后果的方法都没有防护。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 ## 决策 -在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: +在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两部分分别由两个堆叠 PR 实现: -- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 -两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 +两条边界刻意留在范围之外:可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 ## 曾考虑的替代方案 @@ -26,6 +26,6 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 +- 非回环部署的对外服务 authority 必须列入信任范围,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 - 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index fd0e926ace..b74717afb9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.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-28-dsh-native-typescript-source-launch.md 2026-07-28-dsh-native-typescript-source-launch.md: 773f831ec2b116d4908fcd5dc818df78c5deee5e -2026-07-28-dsh-native-typescript-source-launch.zh.md: 0602aa1ba079fcd5bffb2fe3989da9a4f62763fb +2026-07-28-dsh-native-typescript-source-launch.zh.md: 0e40a7e32bfaf1186ce816ec0bc1e608c76b47e0 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 0602aa1ba0..0e40a7e32b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -4,27 +4,27 @@ Status: implemented [English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 -> Node 原生启动向量已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的 fail-loud 插件诊断以及 vendor 中的 `import type` 标注仍然有效。 +> Node 原生启动方案已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的显式失败插件诊断以及 vendor 中的 `import type` 标注仍然有效。 ## 问题 `dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。 -Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。 +Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会转换成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。 -Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。 +Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方的 manifest(元数据清单)可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。 ## 决策 `dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。 -`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 +`scripts/tspath-loader.ts` 只注册一个模块解析钩子。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 -只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,已交付的 `apps/cli/config/base.cordis.yml` 及其界面覆盖层所需依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 +只有当目标包是最近一层包 manifest 的自身名称或该 manifest 已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,已交付的 `apps/cli/config/base.cordis.yml` 及其界面覆盖层所需依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 `verify-cordis-config` 对该解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 -Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 +Loader 完全停稳后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并拒绝启动,报错为 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR(热模块替换)和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs` 和 `.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。 @@ -32,7 +32,7 @@ Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中 **继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。 -**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;零构建开发启动可能读取陈旧产物或直接失败。 +**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;无需预先构建的开发启动可能读取陈旧产物或直接失败。 **无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml index 27ec63f558..187db47b0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.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-28-experimental-plugin-package-group.md 2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80 -2026-07-28-experimental-plugin-package-group.zh.md: f204ecd052de03d0cf347e2c770feb0ea33966c7 +2026-07-28-experimental-plugin-package-group.zh.md: 633549bd47386de3eeb972052ecfdd164878e2f1 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md index f204ecd052..633549bd47 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 实验性与内部专用包(package)分组 +# Agent Note: 实验性与内部专用包分组 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml index 4c701a99f3..34bdadf75b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md -2026-07-28-identified-immutable-message-values.md: cdb0f1aadc4796b5aa0642a3994d3e3e4ab67bd9 -2026-07-28-identified-immutable-message-values.zh.md: 3e1732cb5b7f49fb9349b2e1790cf5b3ec1474be +2026-07-28-identified-immutable-message-values.md: 472de8b133ba323c3e1ff5e53c8dacb3d66525c5 +2026-07-28-identified-immutable-message-values.zh.md: 6082fd5fb65f4acd0759a6c4b49be8110a559a73 diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md index cdb0f1aadc..472de8b133 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md @@ -12,15 +12,15 @@ This made identity a routing side effect rather than a message invariant. Produc ## Decision -`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before routing, prompt admission, durable append, or request projection. The same id survives every representation boundary. +`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before inbox routing, claim, pre-step rewriting, durable append, or request projection. The same id survives every representation boundary. `createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content and model provenance. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement. The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction. -The `Agent` interface accepts a complete `UserMessage`. `send`, `followup`, `steer`, and `inject` never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. +The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. -Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed. +Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message` and `tool/result` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed. Any operation that changes only the representation of an existing semantic message preserves its id and returns another frozen value. An operation that creates a new semantic message mints a new id. Compaction content rewrites therefore preserve the rewritten tool-result identity, while a summary checkpoint is a new message. @@ -28,7 +28,7 @@ Any operation that changes only the representation of an existing semantic messa **Keep ids optional on the base message.** This would minimize fixture migration and allow provider or persistence shapes to remain anonymous. It would also preserve the original ambiguity: every consumer would need to branch on whether identity exists, and no type would prove that admission, logging, or projection retained it. -**Let `Agent.send()` allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before `send()` returns. +**Let agent delivery allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before delivery returns. **Let each durable event allocate a new id.** This gives persisted messages identities but deliberately breaks correlation with the live input and makes replayed requests appear to contain different messages. Identity belongs to the semantic value, not to each envelope that carries it. @@ -38,7 +38,7 @@ Any operation that changes only the representation of an existing semantic messa Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use `freezeMessage()` instead of `createMessage()`. -Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Prompt admission and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established. +Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Pending-input policy and UI attachment cleanup can compare `MessageId` before a turn exists, while claims retain that identity inside the open turn. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established. The shared representation removes the old `UserMessageData`/`AgentMessage` split and folds provider provenance into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata. @@ -46,5 +46,5 @@ The message and helper unit tests pin immediate identity, detachment, deep immut ## Related -- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision. +- [Unified agent delivery routing and coalesced injected context](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision. - [Reconstructable requests](2026-07-05-reconstructable-requests.md) — the session log remains the authority for every model-visible input. diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md index 3e1732cb5b..6082fd5fb6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -harness 曾存在多种形似消息的表示,各自采用不同的标识规则。agent(智能体)输入只有在 loop 接受后才会取得 inbox 关联 id,而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。 +harness 曾存在多种形似消息的表示,各自采用不同的标识规则。agent(智能体)输入只有在 agent loop 接受后才会取得 inbox 关联 id,而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。 -这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 loop 冻结,部分直到会话追加时才冻结,提供方产生的 assistant 输出则使用另一种携带溯源信息的形状。 +这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 agent loop 冻结,部分直到会话追加时才冻结,由提供方生成的 assistant 输出则使用另一种携带溯源信息的形状。 ## 决策 -`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。 +`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于 inbox 路由、领取、pre-step 改写、持久追加或请求投影。同一个 id 会跨越每个表示边界。 -`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把创建表达为导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。 +`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。 这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。 -`Agent` 接口接收完整的 `UserMessage`。`send`、`followup`、`steer` 和 `inject` 绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 +`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 -产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message`、`tool/result` 和 `steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。 +产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message` 和 `tool/result` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。 -仅改变已有语义消息表示的操作会保留其 id,并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此,压缩(compaction)内容改写会保留被改写工具结果的标识,而摘要检查点是一条新消息。 +仅改变已有语义消息表示的操作会保留其 id,并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此,压缩(compaction)中的内容改写会保留被改写工具结果的标识,而摘要检查点是一条新消息。 ## 考虑过的替代方案 **让基础消息的 id 保持可选。** 这能减少 fixture(测试前置数据)迁移,并允许提供方或持久化形状继续保持匿名,但也会保留原有歧义:每个消费方都必须根据标识是否存在执行分支,且没有任何类型能证明准入、记录或投影保留了标识。 -**让 `Agent.send()` 分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在 `send()` 返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。 +**让 agent 交付分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在交付返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。 **让每个持久事件分配新 id。** 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。 @@ -38,13 +38,13 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 `freezeMessage()`,而不是 `createMessage()`。 -实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。 +实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。待处理输入策略和 UI 附件清理可以在轮次存在之前比较 `MessageId`,领取后则会在已打开的轮次内保留该标识。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。 共享表示移除了旧的 `UserMessageData`/`AgentMessage` 划分,并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。 -消息和辅助函数的单元测试会固定即时标识、输入分离、深度不可变性,以及导入 id 的保留。agent loop 测试会固定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会固定冻结派生和保留标识的替换行为。 +消息和辅助函数的单元测试会锁定即时标识、解除输入引用、深度不可变性,以及导入 id 的保留。agent loop 测试会锁定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会锁定冻结派生和保留标识的替换行为。 ## 相关 -- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。 +- [统一 agent 交付路由,并合并注入上下文](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。 - [可重建的请求](2026-07-05-reconstructable-requests.md)——会话日志仍是每项模型可见输入的权威来源。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml index 736a372f27..83f5bbef02 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-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-28-user-settings-seam.md 2026-07-28-user-settings-seam.md: bf93f95168b1b6d0dec5a9fc2c9aac5531f0564a -2026-07-28-user-settings-seam.zh.md: 8cd4dfcbb2facdd590b2c24d453ad79e9badda4d +2026-07-28-user-settings-seam.zh.md: e75cee19b600c3feca2475e609ca87d7e9a3ad7f diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md index 8cd4dfcbb2..e75cee19b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md @@ -1,4 +1,4 @@ -# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider +# Agent Note: 用户设置 seam(`ctx.settings`)与文件 provider Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index ab618103c2..8a6dbf705c 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -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-29-dsh-source-launch-tsx-esm.md 2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 48f410bd846e5808cc95180279348a0ac5ba1c95 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d7b2c47db68f21e904a607f16c80d33c706c488 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 48f410bd84..4d7b2c47db 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -1,4 +1,4 @@ -# Agent Note: dsh 通过 tsx ESM hook 源码启动 +# Agent Note: dsh 通过 tsx ESM 钩子源码启动 Status: implemented @@ -10,15 +10,15 @@ Status: implemented [原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 -启动延迟同样是问题:off-thread 的 `module.register()` hooks worker 把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整 tsx 默认形态(`--import tsx`)的 CJS hook 解析放大要多付约 0.4s。 +启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。 ## 决策 -`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only hook 同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用 hook 与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS hook 保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用钩子与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 -`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader "仅为已声明运行时依赖映射 workspace import" 的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest 走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。) +`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。) -node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块 hook 或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 +node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 ## 备选方案 @@ -26,13 +26,13 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` **把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。 -**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但意味着自行负责转换正确性和一个 tsx 已经提供的 resolve hook。仅当约 0.3s 的差距成为真实成本时再重启;profiling 证据在 PR 讨论中。 +**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但这意味着要自行负责转换正确性,以及实现 tsx 已经提供的解析钩子。仅当约 0.3s 的差距成为真实成本时再重新考虑;性能分析证据在 PR 讨论中。 **Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。 ## 结果 - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 -- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 note "证明 Node 原生转换可用" 的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 +- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS hook 依赖性做审计,且其启动延迟不在交互路径上)。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 047cccbce3..9a0f384e0e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.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-29-projected-token-usage-and-request-context.md 2026-07-29-projected-token-usage-and-request-context.md: 1e2c5ff067928620dee3d0937c247bec245e34f2 -2026-07-29-projected-token-usage-and-request-context.zh.md: 811d92e134b1df0fc6725e6c8d38b37efb57b3aa +2026-07-29-projected-token-usage-and-request-context.zh.md: fc885982ba45df68d456e153093005747ea74984 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 811d92e134..fc885982ba 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -40,7 +40,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节 这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 -**在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包(package)重建日志语义。 +**在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。 **仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失自己的计费用量。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c7861321a0..27da5f7663 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-29-request-level-llm-config-credentials.md 2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a +2026-07-29-request-level-llm-config-credentials.zh.md: 2d132c0fd0ab2205ca013b49226a9764293cc11d diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 99fd90013a..2d132c0fd0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -1,4 +1,4 @@ -# Agent Note:请求级 LLM 配置与凭据 seam +# Agent Note: 请求级 LLM 配置与凭据 seam Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.i18n.yaml new file mode 100644 index 0000000000..2bef24442d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.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-29-terminal-llm-stream-failures.md +2026-07-29-terminal-llm-stream-failures.md: 1e26973360f07c212016c6a44103448a3510a75b +2026-07-29-terminal-llm-stream-failures.zh.md: d3eeb0534f6cb8d4d1ad167cca089314eb02b513 diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md new file mode 100644 index 0000000000..1e26973360 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md @@ -0,0 +1,37 @@ +# Agent Note: Terminal LLM stream failures + +Status: implemented + +English | [中文](2026-07-29-terminal-llm-stream-failures.zh.md) + +This note supersedes only the thrown-error identity and call-local sidecar mechanism in [bounded LLM request recovery](2026-06-21-bounded-llm-request-recovery.md) and [after-call context-overflow recovery](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). Those notes continue to own structured failure facts, retry policy, durable attempts, and compaction recovery. + +## Problem + +An adapter failure had two public representations: an exception from selection, dispatch, iterator construction, or iteration, and an in-band `finish { kind: 'error' | 'aborted' }`. `LlmService` tagged thrown objects in a stream-keyed sidecar so the agent loop could distinguish them from middleware and consumer failures. The consumer still needed a catch around iteration, signal checks, chunk logging, and assembly; correctness therefore depended on proving which statement threw and consulting metadata attached to the exact returned iterable. + +Retry policy had the same indirect ownership. It was discovered through the stream sidecar after dispatch even though `prepareCall()` had already captured the serving registration. A wrapper-owned route and an adapter-owned route consequently shared one opaque lookup API despite having different authority. + +## Decision + +`LlmService` is the normalization boundary for one adapter attempt. It catches only final-adapter selection, synchronous dispatch, iterator construction, and `next()` failures, converts the thrown value to immutable `LlmFailure`, and emits one terminal `finish`. Caller cancellation or an `ABORTED` failure selects the aborted reason; every other adapter failure selects error. An adapter may also emit either terminal reason directly. + +The adapter-owned catch ends before each yielded chunk. Errors from `llm/stream` middleware, nested calls, adapter cleanup, chunk consumers, logging, signal checks, and assembly remain thrown as defects or lifecycle failures; they never enter model-request recovery. A transport failure after partial deltas may leave blocks open, so the stream invariant permits open blocks only for terminal error or aborted finishes. No assistant message or tool call is assembled from that incomplete output. + +`PreparedLlmCall` exposes the immutable retry policy captured with its config and registration. One-shot reuse and config mismatch remain synchronous `INVALID_PREPARED_CALL` misuse errors. A route served entirely by `llm/stream` middleware has no prepared registration and therefore no serving policy. + +The agent loop consumes one failure representation. It iterates and logs chunks without a classification catch, inspects the terminal finish, and passes its failure facts plus the prepared policy to `agent/request-error`. The public `isLlmAdapterFailure`, `llmFailureOf`, and `llmRetryPolicyOf` sidecar APIs are absent. + +## Alternatives considered + +**Keep call-local error tagging.** This preserves thrown object identity, but makes every consumer catch a region containing its own fallible work and couples classification to the identity of an iterable wrapper. The original error object has no durable role in recovery; normalized facts are the useful boundary value. + +**Require every adapter to emit failure chunks and forbid throws.** Library iterators, transports, and JavaScript dispatch can still throw. Requiring every adapter to reproduce the same catch boundary duplicates ownership and does not protect a direct `LlmService` consumer from an incomplete implementation. + +**Catch every iteration error in the agent loop.** The loop cannot reliably distinguish provider failure from middleware, session append, cancellation, or assembly failure without restoring the same sidecar provenance mechanism. Classification belongs where the adapter call is made. + +**Return a `Result` before streaming.** A pre-stream result cannot represent a transport failure after partial output without adding a second response lifecycle. The existing terminal chunk already represents both early and late attempt outcomes. + +## Consequences + +All `LlmService.stream()` consumers receive adapter operational failures through one typed terminal protocol, while programming and lifecycle failures retain ordinary exception semantics. Recovery gives up exact thrown-object identity and exposes only detached provider-neutral facts. The stream service owns slightly more adapter plumbing, but consumers delete provenance catches and stream-keyed metadata. Prepared calls carry their policy explicitly, and middleware-only routing remains visibly policy-free. diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md new file mode 100644 index 0000000000..d3eeb0534f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md @@ -0,0 +1,37 @@ +# Agent Note: LLM 流的终止失败 + +Status: implemented + +[English](2026-07-29-terminal-llm-stream-failures.md) | 中文 + +本说明仅取代[有界 LLM 请求恢复](2026-06-21-bounded-llm-request-recovery.md)与[调用后上下文溢出恢复](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)中关于抛出错误身份和调用局部 sidecar 的机制。上述说明继续规定结构化失败事实、重试策略、持久尝试与压缩恢复。 + +## Problem + +适配器失败曾有两种公共表示:选择、分发、iterator 构造或迭代抛出的异常,以及带内的 `finish { kind: 'error' | 'aborted' }`。`LlmService` 会在以 stream 为 key 的 sidecar 中标记抛出对象,使 agent loop 能将其与 middleware 和消费方失败区分开。消费方仍需用 catch 包围迭代、signal 检查、chunk 日志记录和组装;正确性因此取决于证明是哪条语句抛错,并查询附着于精确返回 iterable 的元数据。 + +重试策略也采用同样的间接归属。尽管 `prepareCall()` 已捕获服务注册,策略仍要在分发后通过 stream sidecar 查找。因此,由 wrapper 提供服务的路由与由适配器提供服务的路由共用一个不透明查询 API,尽管两者的权威不同。 + +## Decision + +`LlmService` 是一次适配器尝试的规范化边界。它只捕获最终适配器选择、同步分发、iterator 构造与 `next()` 失败,将抛出值转换为不可变 `LlmFailure`,并发出一个终止 `finish`。调用方取消或 `ABORTED` 失败选择 aborted reason;其他适配器失败选择 error。适配器也可以直接发出这两种终止 reason。 + +适配器所属的 catch 会在每个 chunk 被 yield 前结束。来自 `llm/stream` middleware、嵌套调用、适配器清理、chunk 消费方、日志记录、signal 检查与组装的错误仍作为缺陷或生命周期失败抛出;它们绝不进入模型请求恢复。部分 delta 之后的传输失败可能留下未关闭块,因此流 invariant 只允许终止 error 或 aborted finish 带有未关闭块。不会从这些不完整输出组装 assistant 消息或工具调用。 + +`PreparedLlmCall` 公开随其配置和注册捕获的不可变重试策略。一次性句柄复用与配置不匹配仍是同步的 `INVALID_PREPARED_CALL` 误用错误。完全由 `llm/stream` middleware 提供服务的路由没有准备完成的注册,因此也没有服务策略。 + +agent loop 只消费一种失败表示。它不再使用分类 catch,而是直接迭代并记录 chunk、检查终止 finish,再把其中的失败事实与准备完成的策略传给 `agent/request-error`。公共的 `isLlmAdapterFailure`、`llmFailureOf` 和 `llmRetryPolicyOf` sidecar API 不再存在。 + +## Alternatives considered + +**保留调用局部错误标记。** 这会保留抛出对象身份,但要求每个消费方捕获一段包含自身易失败工作的区域,并让分类依赖 iterable wrapper 的身份。原始错误对象在持久恢复中没有作用;规范化事实才是有用的边界值。 + +**要求所有适配器发出失败 chunk,并禁止抛出。** 库 iterator、transport 与 JavaScript 分发仍可能抛错。要求每个适配器复制同一 catch 边界会重复归属,也无法保护 `LlmService` 的直接消费方免受不完整实现影响。 + +**在 agent loop 中捕获所有迭代错误。** 如果不恢复同一套 sidecar 溯源机制,loop 无法可靠区分提供方失败与 middleware、session append、取消或组装失败。分类属于发起适配器调用的边界。 + +**在流式输出前返回 `Result`。** 流前结果无法表示部分输出之后的传输失败,除非增加第二套响应生命周期。现有终止 chunk 已能表示早期和后期尝试结果。 + +## Consequences + +所有 `LlmService.stream()` 消费方都通过一种带类型的终止协议接收适配器运行失败,而编程与生命周期失败保留普通异常语义。恢复放弃精确抛出对象身份,只暴露与原对象分离的提供方无关事实。流服务承担略多的适配器管道工作,但消费方删除了溯源 catch 与以 stream 为 key 的元数据。准备完成的调用显式携带策略,而仅由 middleware 路由的调用仍明确没有策略。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index b5eb723680..e062681d87 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.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-30-config-plane-boundaries.md 2026-07-30-config-plane-boundaries.md: 8a29dcc934126d6e3dfa9c0a6a308ef006017a5a -2026-07-30-config-plane-boundaries.zh.md: c858b7dd5b6fcd61936c33f1f09d7d2e89a3cfc7 +2026-07-30-config-plane-boundaries.zh.md: 61edb9b7d8396e0453dcca0042cd0ee27551b216 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index c858b7dd5b..61edb9b7d8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -1,4 +1,4 @@ -# Agent Note:配置面暴露什么,以及谁有权覆盖什么 +# Agent Note: 配置面暴露什么,以及谁有权覆盖什么 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 98f2b0cb0d..1dc80bbf84 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-30-credential-boundaries-and-atomic-registration.md 2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: b58a9e68bb1fa36282ad270542b6a64a0b014726 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 3eb3b02206..b58a9e68bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -32,7 +32,7 @@ Status: implemented - **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。 - **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 -- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 +- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 - **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml new file mode 100644 index 0000000000..2b16888d38 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.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-30-followup-enqueue-and-owned-runs.md +2026-07-30-followup-enqueue-and-owned-runs.md: 54f4af75eeb29b06504fa2629b0665f3e5f4c4ee +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 62c0d998a8bf94df8cabfccf23ea8b2a4da92c4d diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md new file mode 100644 index 0000000000..54f4af75ee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -0,0 +1,42 @@ +# Agent Note: Follow-up enqueue and owned run boundaries + +Status: implemented + +English | [中文](2026-07-30-followup-enqueue-and-owned-runs.zh.md) + +## Problem + +`Agent.followup()` identifies and queues a user message, but one follow-up does not own the activity that follows it. Steering, injected context, tool continuations, recovery, and later queued messages can all contribute before the agent next becomes idle. A `MessageId` can therefore prove inbox admission, but it cannot identify which assistant message or `turn/end` is the result of that input. + +The [one-send-one-turn decision](../simplification/2026-07-17-one-send-one-turn.md) already rejects a per-send completion handle at the core seam. Protocol and SDK layers that pair one prompt request with a turn result manufacture that missing relationship downstream. The pairing becomes ambiguous as soon as activity admits more input, and it exposes turn mechanics as if they were a prompt-level outcome. + +## Decision + +Keep `Agent.followup(message): void` as an enqueue-only operation. `Agent.whenIdle()` and `agent/status` remain whole-agent lifecycle observations; neither settles an individual message. Inbox durability records the identified message and its admission or cancellation, without assigning later output to it. + +The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds with `{ messageId }`. It streams durable facts through `session.event`, publishes whole-agent transitions through `session.status`, and has no `session.finished`. A low-level client may observe that receipt and later idleness, but receives no prompt result. + +High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their `finalResponse` is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. + +ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal. + +Goal continuation retains `MessageId` only to recognize its durable queued and admitted goal message. It advances from durable goal state at whole-agent idle, without mapping the message to a turn result. + +## Alternatives considered + +**Map `MessageId` to the turn that admits it.** A turn may consume steering and injected context and may continue through multiple model/tool steps. The mapping identifies admission, not causal ownership of the resulting output or stop reason. + +**Return a per-follow-up completion handle.** A handle would imply a result boundary that the shared agent lifecycle does not have. It would either omit work that influenced the activity or silently absorb unrelated later input. + +**Use the last `turn/end` observed before idle.** This is a useful run-level observation for an explicitly owned interval, but naming it as the submitted message's outcome recreates the false causal claim. + +## Verification + +- Agent and inbox tests pin enqueue-only follow-up, durable admission or cancellation, and whole-agent idle observation. +- SDK protocol, TypeScript SDK, and Python SDK tests pin the `{ messageId }` receipt, `session.status`, the absence of `session.finished`, and receipt-to-idle `RunResult` collection without prompt-level `status` or `reason`. +- ACP, one-shot CLI, goal continuation, and subagent tests pin the distinct activity ownership each integration possesses. +- Consumer tests pin that no production integration derives a follow-up result by correlating `MessageId` with `turn/end`. + +## Consequences + +An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications disappear from SDK and ACP results; callers that need those facts must inspect the durable event stream without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md new file mode 100644 index 0000000000..62c0d998a8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -0,0 +1,42 @@ +# Agent Note: follow-up 入队与自有运行边界 + +Status: implemented + +[English](2026-07-30-followup-enqueue-and-owned-runs.md) | 中文 + +## 问题 + +`Agent.followup()` 会标识一条用户消息并将其排入队列,但单次 follow-up 并不拥有随后发生的活动。在 agent(智能体)下一次进入 idle 前,steering(中途引导)、注入的上下文、工具续行、恢复和后续排队消息都可能参与活动。因此,`MessageId` 可以证明 inbox 已准入,但不能标识哪一条 assistant 消息或哪一个 `turn/end` 是该输入的结果。 + +[one-send-one-turn 决策](../simplification/2026-07-17-one-send-one-turn.md) 已经在核心 seam 中排除了按 send 返回完成句柄的设计。凡是把一项提示词请求与一个轮次结果配对的协议层和 SDK 层,都会在下游人为构造这一缺失的关系。一旦活动准入更多输入,该配对就会产生歧义,还会把轮次机制暴露为提示词级结果。 + +## 决策 + +保留 `Agent.followup(message): void`,使其仅执行入队。`Agent.whenIdle()` 和 `agent/status` 仍用于观察整个 agent 的生命周期;二者都不结算单条消息。Inbox 持久性会记录已标识消息及其准入或取消,但不会把后续输出归属于该消息。 + +底层 SDK 协议在入队成功后立即以 `{ messageId }` 响应 `session/prompt`。它通过 `session.event` 传输持久事实,通过 `session.status` 发布整个 agent 的状态转换,且不包含 `session.finished`。底层客户端可以观察该回执和之后的 idle,但不会收到提示词结果。 + +只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其 `finalResponse` 是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 + +ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle,其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误 reject 提示词(错误按其所属轮次归因),而 turnless 槽位(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或资源释放并列。 + +Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal 消息。它在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。 + +## 考虑过的替代方案 + +**将 `MessageId` 映射到准入它的轮次。** 一个轮次可能使用 steering 和注入的上下文,还可能经过多个模型/工具步骤继续执行。该映射只能标识准入,不能确立结果输出或停止原因的因果归属。 + +**返回按 follow-up 区分的完成句柄。** 这样的句柄暗示共享 agent 生命周期中存在并不实际成立的结果边界。它要么遗漏影响活动的工作,要么在不作说明的情况下吸收后续无关输入。 + +**使用进入 idle 前观察到的最后一个 `turn/end`。** 对于明确拥有的区间,这是一项有用的运行级观测;但如果将其命名为已提交消息的结果,就会再次作出错误的因果声明。 + +## 验证 + +- Agent 与 inbox 测试固定 follow-up 仅入队、持久准入或取消以及整个 agent 的 idle 观测。 +- SDK 协议、TypeScript SDK 和 Python SDK 测试固定 `{ messageId }` 回执、`session.status`、不存在 `session.finished`,以及不含提示词级 `status` 或 `reason` 的回执到 idle `RunResult` 收集。 +- ACP、单次 CLI、goal 续行和 subagent 测试固定各集成实际拥有的不同活动边界。 +- 消费方测试固定生产集成都不会通过关联 `MessageId` 与 `turn/end` 来推导 follow-up 结果。 + +## 后果 + +自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应和事件有意比初始消息涵盖更广。SDK 和 ACP 结果不再包含提示词级模型错误和 token 上限分类;需要这些事实的调用方必须检查持久事件流,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index 3c04e9b255..ab893fbb26 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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-30-settings-write-path-integrity.md 2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a -2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992 +2026-07-30-settings-write-path-integrity.zh.md: 600a3c7269510673433969c92d322fd1186d38f3 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index da07745ef1..600a3c7269 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -38,4 +38,4 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 `update()` 对锁获取期限与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 -[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 +[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包的那些 PR(Pull Request)所有,向上合并时按本模板处理。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index fedfc7e489..647e4649d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-30-web-config-plane.md -2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 -2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 +2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7 +2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 6d1a8c242c..5225460be1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-30-web-config-plane.zh.md) -> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. +> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. ## Problem @@ -12,15 +12,17 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin. +**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. + **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. +**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. ## Alternatives considered @@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer - **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index c3255cacfd..53a21ddf31 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -1,10 +1,10 @@ -# Agent Note:web 配置平面 +# Agent Note: web 配置平面 Status: implemented [English](2026-07-30-web-config-plane.md) | 中文 -> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 +> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 ## 问题 @@ -12,15 +12,17 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 + **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 ## 曾考虑的替代方案 @@ -30,7 +32,8 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 - **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml new file mode 100644 index 0000000000..3128996f1e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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-31-claimed-pre-step-inbox-lifecycle.md +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 5184c00335b3084a8eb6d58fa33a0af8d3a16ed6 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 4966ccc8a3a7535022c04f9445ca75102ccca71e diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md new file mode 100644 index 0000000000..5184c00335 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -0,0 +1,41 @@ +# Agent Note: Claim inbox input before one pre-step decision + +Status: implemented + +English | [中文](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md) + +## Problem + +The loop previously split one step boundary across prompt preparation, prompt admission, and a serial step hook. Claimed input could be retained or discarded by an admission result, and live queue events carried shapes that duplicated durable inbox state. Plugins had to choose whether to mutate the inbox, rewrite a submitted batch, or append directly to session history, while observers could not rely on one exact ordering. + +Occurrence-local inbox wrappers also duplicated the identity already carried by every `UserMessage`. They made insertion, editing, claiming, cancellation, reconnect projection, and step entry one combined protocol even though the append-only session already owned the durable queue projection. + +## Decision + +Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`. + +`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` seam. + +The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields. + +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change. + +Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. + +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection. + +## Alternatives considered + +**Keep separate prepare and admit hooks.** This lets preparation mutate the inbox before claiming and admission rewrite afterward, but it creates two ordering surfaces for one boundary and makes cancellation ownership ambiguous. + +**Let rejection requeue the claimed batch.** This preserves retry-like behavior but turns a veto into hidden queue mutation, duplicates later work unless every race is fenced, and prevents claim from being an atomic ownership transfer. + +**Put placement and outcome on every live event.** Durable splices already own those facts. Repeating them on live notifications creates a second contract that can drift and is unnecessary for consumers holding the exact message identity. + +## Verification + +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, workspace-context staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new seam and payloads. + +## Consequences + +The loop has one awaited decision before each step and one ownership transfer for its input. Claimed messages never return to the inbox implicitly; later insertions remain independent. Live events are symmetrical with other inbox notifications without mirroring durable metadata, and plugins can choose exact-current-step rewriting or ordinary later inbox delivery explicitly. diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md new file mode 100644 index 0000000000..4966ccc8a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -0,0 +1,41 @@ +# Agent Note:在单一 pre-step 决策前领取 inbox 输入 + +Status: implemented + +[English](2026-07-31-claimed-pre-step-inbox-lifecycle.md) | 中文 + +## 问题 + +循环此前把一个步骤边界拆成提示词准备、提示词准入与串行 step hook。准入结果可以保留或丢弃已领取输入,实时队列事件还携带了与持久 inbox 状态重复的形状。插件不得不在修改 inbox、改写已提交批次与直接追加会话历史之间选择,而观察方无法依赖一套明确顺序。 + +单次出现专属的 inbox wrapper 也重复了每个 `UserMessage` 已有的标识。它把插入、编辑、领取、取消、重连投影与步骤进入合并成一套协议,但仅追加会话本就拥有持久队列投影。 + +## 决策 + +每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 + +`PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` seam。 + +持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 + +两类事件表面服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除经 `Inbox.splice()` 或其他 Inbox 变更方法进入,从而让同一投影记录所有变化。 + +必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 + +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现 wrapper 设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。 + +## 曾考虑的替代方案 + +**保留分离的 prepare 与 admit hook。** 这样准备阶段可以在领取前修改 inbox,准入阶段可以在领取后改写,但同一边界会出现两个顺序表面,取消归属也会变得模糊。 + +**reject 时把已领取批次重新入队。** 这看似保留重试行为,却会让否决隐式修改队列;若不为每个竞态加围栏,还会复制后续工作,并使 claim 无法成为原子所有权转移。 + +**在每个实时事件上携带 placement 与 outcome。** 持久 splice 已经拥有这些事实。实时通知重复它们会建立可能漂移的第二份契约,而持有确切消息标识的消费方并不需要这些字段。 + +## 验证 + +Agent-loop 覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、workspace-context 的暂存、替换与同一步骤进入、plan/goal/hook 行为、UI 清理、压缩、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 seam 与载荷。 + +## 后果 + +循环在每个步骤前只有一个需等待的决策,对输入也只有一次所有权转移。已领取消息不会隐式返回 inbox;后续插入保持独立。实时事件与其他 inbox 通知保持对称,但不镜像持久元数据;插件可以显式选择精确的当前步骤改写,或普通的后续 inbox 投递。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml index 75c93214c7..589ecbf5fd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 04bcaa26be2f4f70c4003f6cc2fce4798499d205 -2026-07-29-addressable-queue-operations.zh.md: a16bf8667294de8d08da54ddb0d317bf9938e4eb +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md +2026-07-31-goal-owned-durable-events.md: ac0358469958319f7629adb5e96845b7e0013297 +2026-07-31-goal-owned-durable-events.zh.md: 9b45cdd7990bfc1fdfbb63650e40b41e5ba2918d diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md new file mode 100644 index 0000000000..ac03584699 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md @@ -0,0 +1,33 @@ +# Agent Note: Goal-owned durable events + +Status: implemented + +English | [中文](2026-07-31-goal-owned-durable-events.zh.md) + +## Problem + +Goal state and inbox state have different lifecycles. A goal mutation must survive restart and fork whether or not any related model context is admitted, while an inbox message may be edited, claimed, rejected, or discarded as part of step scheduling. Encoding a goal mutation inside a round-zero inbox message made queue placement the domain commit point and required replay to reconcile insertion, admission, message identity, source metadata, and rendered content. + +The goal domain needs durable state, but it does not need ownership of pending model input. Continuation scheduling still needs the inbox; goal persistence does not. + +## Decision + +`@deepseek-ai/dsh-goal` owns a durable `goal/change` session event. Each event carries the complete post-mutation goal snapshot or a revisioned clear tombstone. `GoalService` appends that event synchronously, then emits `goal/changed`; strict replay and the `goal` session projection fold only `goal/change` for lifecycle state. + +`GoalMessageSource` identifies only positive admitted continuation rounds. A matching `user/message` advances `roundsStarted`; ordinary user messages and inbox splice events do not change goal state. The goal package never inserts, claims, removes, or inspects inbox messages. `@deepseek-ai/dsh-goal-session` remains responsible for queuing and tracking its own continuation prompts through the public inbox lifecycle. + +Activation remains process-local. The service associates the synchronously appended event sequence with the requested activation while its cache observes the event; replayed or externally appended changes default to disarmed. The session log remains the only durable authority. + +The domain does not automatically project each mutation into model input. Goal tools return current state, and continuation prompts include the objective and round state when work is actually scheduled. Any future always-visible goal context is a separate context plugin that owns its inbox message rather than a persistence side effect. + +## Alternatives considered + +- **Keep round-zero goal messages as the durable record.** Rejected because it couples domain commits to queue mutation and requires the goal fold to understand claim and admission reconciliation even though queue outcomes cannot roll back domain state. +- **Derive goal state only from model-visible messages.** Rejected because a mutation may be valid and durable without opening a step, and cancellation or policy rejection must not erase it. +- **Store goals in a separate database.** Rejected because the ordered session log already supplies persistence, replay, and fork inheritance without a second atomicity boundary. + +## Consequences + +Goal state is independent of inbox placement and admission. Replay has one mutation path, projections advance directly on `goal/change`, and continuation messages carry only round attribution. The model does not receive a mutation-only `` message; model-visible state appears through goal tools and scheduled continuation prompts. Direct session writers remain trusted and can append malformed changes, which the strict fold and invariant companion reject. + +Focused goal, goal-session, command, TUI, and client-fixture tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round. diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md new file mode 100644 index 0000000000..9b45cdd799 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Goal 自有的持久事件 + +Status: implemented + +[English](2026-07-31-goal-owned-durable-events.md) | 中文 + +## 问题 + +Goal 状态与 inbox 状态具有不同的生命周期。无论相关模型上下文是否获准进入步骤,goal 变更都必须在重启与 fork 后保留;inbox 消息则可能在步骤调度期间被编辑、领取、拒绝或丢弃。把 goal 变更编码到 Round 为 0 的 inbox 消息中,会让队列放置成为领域提交点,并迫使回放对账插入、准入、消息标识、来源元数据与渲染内容。 + +Goal 领域需要持久状态,但不需要拥有待处理的模型输入。继续执行调度仍然需要 inbox;goal 持久化不需要。 + +## 决策 + +`@deepseek-ai/dsh-goal` 拥有持久的 `goal/change` 会话事件。每个事件携带变更后的完整 goal 快照,或带修订号的清除墓碑。`GoalService` 同步追加该事件,再发出 `goal/changed`;严格回放与 `goal` 会话投影只折叠 `goal/change` 来获得生命周期状态。 + +`GoalMessageSource` 只标识已准入且为正数的继续执行 Round。匹配的 `user/message` 会推进 `roundsStarted`;普通用户消息与 inbox splice 事件不会改变 goal 状态。Goal 包不会插入、领取、移除或检查 inbox 消息。`@deepseek-ai/dsh-goal-session` 仍通过公开 inbox 生命周期负责排队和跟踪自己的继续执行提示词。 + +激活态仍只存在于进程中。服务在缓存观察事件时,将同步追加的事件序号与目标激活态关联;回放或外部追加的变更默认处于 `disarmed`。会话日志仍是唯一的持久权威。 + +该领域不会自动把每次变更投影为模型输入。Goal 工具返回当前状态;真正调度工作时,继续执行提示词包含目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件拥有其 inbox 消息,而不是把它作为持久化副作用。 + +## 考虑过的替代方案 + +- **继续以 Round 为 0 的 goal 消息作为持久记录。** 不予采纳,因为这会把领域提交与队列变更绑定,并要求 goal 折叠理解领取和准入对账,尽管队列结果不能回滚领域状态。 +- **只从模型可见消息派生 goal 状态。** 不予采纳,因为变更可以在不打开步骤的情况下有效且持久,取消或策略拒绝也不能擦除它。 +- **把 goal 存入独立数据库。** 不予采纳,因为有序会话日志已经提供持久化、回放与 fork 继承,无需引入第二个原子性边界。 + +## 后果 + +Goal 状态不依赖 inbox 放置与准入。回放只有一条变更路径,投影直接由 `goal/change` 推进,继续执行消息只携带 Round 归属。模型不会收到仅用于变更的 `` 消息;模型可见状态来自 goal 工具与已调度的继续执行提示词。直接写入会话的插件仍受信任,并且可以追加畸形变更;严格折叠与 invariant 配套模块会拒绝这些变更。 + +聚焦的 goal、goal-session、command、TUI 与 client fixture 测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..2a4879b6aa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: b41ad687725c55acb8517fe7e93d645f007a0453 +2026-08-04-websocket-downlink-carrier.zh.md: 568240ec14592fba8444e5cc0a3bae2b35c45b94 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md new file mode 100644 index 0000000000..b41ad68772 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -0,0 +1,39 @@ +# Agent Note: WebSocket carrier for browser downlinks + +Status: implemented + +English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) + +## Problem + +The browser Web GUI has long used two SSE responses for `events.mux` and `events.host`. HTTP/1.1 browsers typically allow only about six concurrent connections per origin; each page permanently occupying two makes same-origin tabs, plugin resources, and ordinary RPCs contend for connection slots, and reaching the limit causes requests to queue rather than merely slowing them down. The RPC protocol itself is channel-independent: a constraint of the browser's physical carrier must not leak into the session/runtime object layer. + +## Decision + +The real browser carrier opens one independent WebSocket for each downlink stream class: `/api/events.mux` sends only `MuxFrame`, and `/api/events.host` sends only `HostFrame`. Each text message is one complete `ServerRequest` JSON document; the client continues to validate the envelope first, then the concrete frame union for that path, and passes the narrow `RpcRequest` form to the existing `ConnectionController`. The streams retain independent lifecycles and provide no cross-stream ordering guarantee; either one ending still fails the entire connection generation and rebuilds it under the existing backoff policy. + +WebSocket carries only the host→browser downlink. All client→host unary calls and `respond` operations for server requests continue to use the existing `POST /api/*`; the WebSocket accepts no client application messages. `WebApiClient` therefore holds HTTP `fetch` for uplink and WebSocket for downlink, while the fixture and `InProcessApiClient(toFetchHandler(api))` continue to implement the same two-stream `IApiClient` abstraction. The in-process fetch carrier retains SSE encoding and decoding to verify the channel-independent protocol's isomorphism, but network GET requests to `/api/events.*` answer only Upgrade Required and do not provide a browser compatibility fallback. + +## Upgrade and lifecycle boundaries + +`dsh-host-webserver` provides an exact upgrade-route registration seam alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. + +A browser abort or socket close cancels the corresponding host stream; plugin teardown also waits for that source iterator's cleanup. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. + +## Verification + +Webserver contract tests pin upgrade-pathname dispatch, duplicate-registration rejection, disposal, and teardown; connection real-network tests pin each WebSocket's trust check, open, schema envelope, frame order, stream error, and close cancellation; client tests also prove that downlinks create `ws:`/`wss:` URLs while unary calls and `respond` still use HTTP `fetch`. The assembled keyless browser replay continues to cover Chromium, a real host, HTTP uplink, and the full WebSocket downlink chain. + +## Alternatives considered + +**Multiplex mux and host over one WebSocket.** This would add a channel tag, a multiplexing queue, and a single-connection backpressure policy, and would change the existing two-stream readiness semantics. Two WebSockets already avoid the HTTP/1.1 six-connection limit while keeping this change in the physical carrier layer. + +**Move unary calls and respond to a full-duplex WebSocket as well.** This would rewrite timeout, cancellation, HTTP-status, trust-fence, and request-correlation behavior without adding any benefit for the current downlink connection-slot problem. HTTP uplink is an explicitly retained boundary. + +**Keep a network SSE fallback.** Two carriers would let the production browser path silently fork because of proxy or handshake differences and would leave the connection-limit problem in a supported branch. During prerelease, only WebSocket downlink ships; the existing reconnect behavior and connection state expose failures explicitly. + +**Rely on HTTP/2 for greater connection concurrency.** The built-in development server uses plaintext Node HTTP/1.1, and a deployment's fronting proxy is not a product invariant. The physical downlink directly uses a browser primitive outside that connection pool. + +## Consequences + +Each Web page still has two long-lived downlink connections, but they no longer consume the browser's six-connection HTTP/1.1 quota. The runtime continues to consume the original two streams and retains all reconnect, seam-repair, and cross-stream unordered semantics. The cost is one more upgrade-registration surface in the webserver, a WebSocket implementation dependency in the connection package's host half, and separate maintenance of the browser WebSocket and in-process SSE physical codecs. They share the same `ServerRequest`/frame schemas and `IApiClient` semantics, avoiding a second application protocol. diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md new file mode 100644 index 0000000000..568240ec14 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 浏览器下行 WebSocket 载体 + +Status: implemented + +[English](2026-08-04-websocket-downlink-carrier.md) | 中文 + +## Problem + +浏览器 Web GUI 的 `events.mux` 与 `events.host` 长期使用两条 SSE(Server-Sent Events)响应。HTTP/1.1 浏览器通常只允许每个来源约六条并发连接;每个页面永久占住两条会让同源多标签页、插件资源和普通 RPC 争抢连接槽,达到上限后不是降速而是排队阻塞。RPC 协议本身是通道无关的,约束来自浏览器物理载体,不应渗入 session/runtime 对象层。 + +## Decision + +浏览器真实载体为两类下行流各开一条独立 WebSocket:`/api/events.mux` 只发送 `MuxFrame`,`/api/events.host` 只发送 `HostFrame`。每条 text message 是一份完整的 `ServerRequest` JSON;客户端继续先校验信封,再按路径校验具体 frame union,并把窄形 `RpcRequest` 交给既有 `ConnectionController`。两条流保持独立生命周期和无跨流顺序保证,任一条结束仍使整个 connection generation 失败并按既有退避策略重建。 + +WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和对 server request 的 `respond` 继续使用既有 `POST /api/*`;不在 WebSocket 上接收任何客户端业务 message。`WebApiClient` 因而同时持有 HTTP `fetch` 上行与 WebSocket 下行,而 fixture 和 `InProcessApiClient(toFetchHandler(api))` 继续实现同一 `IApiClient` 双流抽象。进程内 fetch 载体保留 SSE 编解码来检验通道无关的协议同构,但网络 `/api/events.*` GET 只回答 upgrade required,不作为浏览器兼容回退。 + +## Upgrade 与生命周期边界 + +`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册缝,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket message。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和 stream cancellation,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 + +浏览器 abort 或 socket close 会取消对应的 host stream;plugin teardown 还会等待该 source iterator 完成清理。host stream 中途抛错时,载体发送一份现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 + +## Verification + +webserver 契约测试钉住 upgrade pathname 分发、重复注册拒绝、disposer 与 teardown;connection 的真实网络测试钉住两条 WebSocket 各自的信任检查、open、schema 信封、frame 顺序、stream error 与 close cancellation;客户端测试同时证明下行创建 `ws:`/`wss:` URL,而 unary 与 `respond` 仍调用 HTTP `fetch`。组装后的 keyless browser replay 继续覆盖 Chromium、真实 host、HTTP 上行与 WebSocket 下行整链。 + +## Alternatives considered + +**用一条 WebSocket 复用 mux 与 host。** 这会新增 channel tag、复用队列与单连接背压策略,并改变现有双流 readiness 语义;两条 WebSocket 已避开 HTTP/1.1 六连接上限,同时让本次变更保持在物理载体层。 + +**把 unary 与 respond 一并迁入全双工 WebSocket。** 这会改写超时、取消、HTTP 状态、信任栅栏和请求关联面,却不能为当前的下行连接槽问题带来额外收益;上行 HTTP 是明确保留的边界。 + +**保留网络 SSE 回退。** 双载体会让生产浏览器路径可因代理或握手差异静默分叉,并让连接上限问题继续存在于一个受支持分支;预发布阶段只交付 WebSocket 下行,失败由既有重连与连接状态显式呈现。 + +**依赖 HTTP/2 扩大并发连接能力。** 内置开发服务器是明文 Node HTTP/1.1,部署前置代理也不是产品可依赖的不变式;物理下行应直接使用不受该连接池限制的浏览器原语。 + +## Consequences + +每个 Web 页面仍有两条长期下行连接,但它们不再消耗浏览器的 HTTP/1.1 六连接配额;runtime 继续消费原有双流并保留所有重连、补缝和跨流无序语义。代价是 webserver 多一个 upgrade 注册面,connection host 半依赖 WebSocket 实现,并需分别维护浏览器 WebSocket 与进程内 SSE 两种物理编解码;它们共享同一 `ServerRequest`/frame schema 和 `IApiClient` 语义,避免形成第二套业务协议。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml new file mode 100644 index 0000000000..8a4b3606a8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 039e0c193179677b57e742d55f4c7df6bdff852f diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md new file mode 100644 index 0000000000..eab53c6838 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -0,0 +1,52 @@ +# Agent Note: Large-session JSONL restore pipeline + +Status: implemented + +English | [中文](2026-08-05-large-session-jsonl-restore-pipeline.zh.md) + +## Problem + +Restoring a stored session activates it and materializes its complete authoritative event log before the agent can run. Large JSONL artifacts made that one-time operation pay several avoidable costs: each independent Zstandard frame created and closed a decoder context, decoded plaintext was accumulated and rescanned as whole-log buffers and strings, and freshly parsed events went through generic snapshot and deep-freeze paths designed for borrowed or cyclic values. + +A representative profile contained 61.8 MiB of Zstandard data, 97.1 MiB of plaintext, and 1,307,073 events. The restore path must reduce its CPU and memory cost without weakening checksum validation, committed-region corruption detection, torn-tail recovery, sequence and surface validation, or the session log's immutability. + +## Decision + +Restoration is one ownership-transfer pipeline from the persistence artifact into `Session.fromRestore`. The compressed artifact remains the source buffer, while each decoding and scanning stage consumes the previous stage's output incrementally without retaining a whole-log plaintext or parsed copy; the resulting event array is the only complete decoded representation. + +### Frame decoding + +The structural Zstandard scanner identifies complete frame ranges before decoding. The dedicated first frame is decoded and parsed separately as the session header; subsequent plaintext frames are yielded in order into the JSONL scanner. + +`ZstdFrameDecoder` gives the reader one lifecycle for interchangeable synchronous implementations. The preferred implementation probes the supported Node 22, 24, and 26 stream shape, reuses one private native decoder context and scratch buffer across all complete frames, and closes it once. If that private shape is unavailable, the factory selects a public `zstdDecompressSync` implementation with the same iterator and checksum-error contract. A yielded scratch view is consumed before the iterator advances. + +After approximately 500 ms of accumulated frame work, the asynchronous reader yields at the next frame boundary and observes cancellation before continuing. A single frame remains an indivisible synchronous operation. Complete frames require end-of-frame and checksum validation; only a structurally incomplete final frame uses the existing prefix decoder for recovery. + +### Incremental JSONL scanning + +`SessionLogScanner` searches raw buffers with `Buffer.indexOf(0x0A)` and converts only complete records to UTF-8 for `JSON.parse`. It carries an incomplete record across decoder writes and copies only that fragment because the private decoder may reuse its output buffer. It does not build a whole plaintext buffer or string, a line array, or a second parsed-record array. + +The scanner stops retaining events at the first unparsable row or sequence gap but continues inspecting later complete records. A later `turn/end` proves that the issue lies in the committed region and rejects the log. The Zstandard reader also rejects any unresolved parse, sequence, or partial-record issue after all complete frames; only a structurally torn final frame may contribute a recoverable suffix. Complete records emitted from that torn frame pass through the same scanner and retain the existing repair offset and recovered-event semantics. + +### Restore admission + +Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice. + +Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values. + +## Alternatives considered + +- **One asynchronous native operation per frame** — rejected because dispatch and callback overhead dominates logs containing many small durable batches. Cooperative synchronous decoding pays that overhead only at periodic yield boundaries. +- **Process the complete log synchronously without yielding** — rejected because it prevents cancellation and event-loop progress for the full restore duration. Frame-boundary yields retain a bounded observation point without splitting codec operations. +- **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. +- **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. +- **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. + +## Consequences + +On the representative profile, incremental scanning reduced JSONL scan time from about 598 ms to 397 ms and peak RSS from about 1,494 MiB to 1,060 MiB. Restore admission reduced `Session.fromRestore` from 604–608 ms to about 263 ms, including an `assertSessionEventEnvelope` reduction from about 77 ms to 13 ms. These measurements characterize the optimization input rather than establish runtime limits. + +The fast decoder depends on runtime-probed Node internals, but incompatibility selects the public implementation rather than changing correctness. Cancellation is observed around cooperative frame-boundary yields; the deadline is not a hard wall-clock bound inside one frame. The complete event array remains resident because it is the active session's authoritative log; the pipeline removes duplicate representations rather than paginating that state. + +Tests force both decoder implementations, compare their frame order and corruption behavior, exercise cooperative cancellation and torn-tail recovery, and retain the existing session envelope, surface, and immutability contracts. diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md new file mode 100644 index 0000000000..039e0c1931 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 大型会话 JSONL 恢复流水线 + +Status: implemented + +[English](2026-08-05-large-session-jsonl-restore-pipeline.md) | 中文 + +## 问题 + +恢复已存储会话会激活该会话,并在 agent(智能体)运行前物化完整且权威的事件日志。处理大型 JSONL 产物时,这个一次性操作会产生几项不必要的开销:每个独立 Zstandard 帧都会创建并关闭一个解码上下文;解码后的明文会汇总成整份日志的缓冲区和字符串,再进行重复扫描;刚解析出的事件还会进入面向借用值或循环引用值设计的通用快照与深度冻结路径。 + +一份代表性性能剖析包含 61.8 MiB Zstandard 数据、97.1 MiB 明文和 1,307,073 个事件。恢复路径必须降低 CPU 与内存开销,同时保持校验和验证、已提交区域损坏检测、撕裂尾部恢复、序列与 `surface` 校验,以及会话日志不可变性。 + +## 决策 + +恢复过程是一条从持久化产物进入 `Session.fromRestore` 的所有权转移流水线。压缩产物仍作为源缓冲区驻留,但解码与扫描阶段会增量消费上一阶段的输出,不会保留整份日志的明文或解析副本;最终事件数组是唯一完整的已解码表示。 + +### 帧解码 + +Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解码专用首帧并将其解析为会话头部,后续明文帧则按顺序产出并送入 JSONL 扫描器。 + +`ZstdFrameDecoder` 为可互换的同步实现提供统一生命周期。首选实现会探测受支持 Node 22、24 与 26 的流结构,在所有完整帧之间复用一个私有原生解码上下文和临时缓冲区,最后只关闭一次。如果私有结构不可用,工厂会选择使用公共 `zstdDecompressSync` 的实现,并保持相同的迭代器和校验和错误契约。迭代器产出的临时视图会在进入下一次迭代前被消费。 + +累计帧处理时间约达 500 ms 后,异步读取器会在下一帧边界让出事件循环,并在继续前观察取消信号。单个帧仍是不可分割的同步操作。完整帧必须通过帧结束与校验和验证;只有结构上不完整的最终帧才使用既有前缀解码器进行恢复。 + +### 增量 JSONL 扫描 + +`SessionLogScanner` 使用 `Buffer.indexOf(0x0A)` 在原始缓冲区中查找换行,只把完整记录转换为 UTF-8 并交给 `JSON.parse`。扫描器会跨解码写入保留不完整记录;由于私有解码器可能复用输出缓冲区,它只复制这个片段。扫描过程不会构造整份明文缓冲区或字符串,也不会构造行数组或第二份解析记录数组。 + +扫描器在遇到第一条无法解析的记录或序列缺口后停止保留事件,但会继续检查后续完整记录。后续出现 `turn/end`,说明问题位于已提交区域,系统会拒绝该日志。处理完所有完整帧后,如果仍存在未决的解析错误、序列错误或部分记录,Zstandard 读取器同样会拒绝日志;只有结构上撕裂的最终帧才能提供可恢复后缀。该撕裂帧产出的完整记录会经过同一扫描器,并保持既有修复偏移量与恢复事件语义。 + +### 恢复准入 + +持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。 + +普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。 + +## 考虑过的替代方案 + +- **每帧执行一次异步原生操作**:不予采纳,因为对于包含大量小型持久化批次的日志,调度与回调开销占据主要部分。协作式同步解码只在周期性让出边界支付这类开销。 +- **同步处理完整日志且不让出事件循环**:不予采纳,因为整个恢复期间都无法响应取消或推进事件循环。帧边界让出机制无需拆分编解码操作,就能保留有界的观察点。 +- **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 +- **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 +- **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 + +## 后果 + +在代表性性能剖析中,增量扫描将 JSONL 扫描时间从约 598 ms 降至 397 ms,峰值 RSS 从约 1,494 MiB 降至 1,060 MiB。恢复准入将 `Session.fromRestore` 从 604–608 ms 降至约 263 ms,其中 `assertSessionEventEnvelope` 从约 77 ms 降至 13 ms。这些数据用于描述优化输入,不构成运行时上限。 + +快速解码器依赖运行时探测的 Node 内部接口,但接口不兼容时会改用公共实现,不会改变正确性。系统会在协作式帧边界让出点观察取消信号;截止时间并不是单个帧内部严格的挂钟时间上限。完整事件数组仍会驻留内存,因为它是活跃会话的权威日志;该流水线移除的是重复表示,并未对这份状态做分页。 + +测试会强制执行两种解码器实现,比对帧顺序和损坏处理行为,覆盖协作式取消与撕裂尾部恢复,并保留既有会话信封、`surface` 与不可变性契约。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml new file mode 100644 index 0000000000..09e63a6a1c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md +2026-08-05-session-preparation.md: 69d39f552ed3041403a24b5aefb435e4e721b09c +2026-08-05-session-preparation.zh.md: a0ca27eb63552566c918c299bd5fba976687812c diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md new file mode 100644 index 0000000000..69d39f552e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md @@ -0,0 +1,68 @@ +# Agent Note: Reusable Session preparation before publication + +Status: implemented + +English | [中文](2026-08-05-session-preparation.zh.md) + +## Problem + +Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point. + +Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. + +## Decision + +`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`. + +The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior. + +This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-seams.md) without replacing its ownership model. + +## Persisted preparation lifecycle + +A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence. + +`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release. + +`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation. + +The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md). + +## History and resume reuse + +History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again. + +If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view. + +Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read. + +## Boundaries + +- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation, synthesizes logical closers, or joins the LRU. +- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted. +- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity. +- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject. +- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation. +- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation. + +## Verification + +The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown. + +## Alternatives considered + +**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. + +**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit. + +**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store. + +**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary. + +**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics. + +## Consequences + +One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities. + +The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization. diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md new file mode 100644 index 0000000000..a0ca27eb63 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md @@ -0,0 +1,68 @@ +# Agent Note: 发布前可复用的 Session 准备阶段 + +Status: implemented + +[English](2026-08-05-session-preparation.md) | 中文 + +## 问题 + +冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合。 + +新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 + +## 决策 + +`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象。 + +agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上 dispose 准备对象。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。 + +该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-seams.md)中的发布边界,但不替换其所有权模型。 + +## 持久化准备生命周期 + +使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与平衡逻辑事件日志构成读取方借用的 `SessionInspection`,revision 则保留在持久化内部。 + +`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。 + +`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。 + +存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.md)所规定的存储与恢复规则。 + +## 历史与恢复复用 + +历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。 + +如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。 + +冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.md)中的生命周期与授权规则,也消除了重复冷读。 + +## 边界 + +- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU。 +- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。 +- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。 +- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。 +- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。 +- Revision 校验在复用点和修复提交点建立新鲜性,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。 + +## 验证 + +共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。 + +## 考虑过的替代方案 + +**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。 + +**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。 + +**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复。 + +**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。 + +**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。 + +## 后果 + +一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。 + +首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml new file mode 100644 index 0000000000..43c5433343 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md +2026-08-05-slot-declaration-injection.md: cb15125977c060144553d7cf75e3c2c26fb1b23b +2026-08-05-slot-declaration-injection.zh.md: 385cab875bb445ba1ca324fc9b45363b8daf50b6 diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md new file mode 100644 index 0000000000..cb15125977 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md @@ -0,0 +1,45 @@ +# Agent Note: Slot declaration injection and reload lifetimes + +Status: implemented + +English | [中文](2026-08-05-slot-declaration-injection.zh.md) + +## Problem + +Client plugins may contribute to a slot before or after the plugin that declares it. Cordis service injection cannot express this dependency: a service is only an indirect ordering signal, client manifest dependency rows do not sequence activation, and a slot can disappear and return while every related service remains mounted. Registering immediately therefore races an undeclared slot, while waiting on an unrelated service couples independently reloadable features. + +Slot-level hot replacement also requires two independent owners. Removing the declaring plugin must remove every contribution under its child slots; removing a contributing plugin must remove only that plugin's entries. A replacement declaration with the same key is a new lifetime even when disappearance and reappearance batch into one notification. + +## Decision + +`SlotsService.inject(name, callback)` makes the declared slot itself the dependency. The full `SlotMap` key is statically checked; there is no namespace builder, synthetic Cordis service, or slot-specific `Context`. The callback runs immediately when the declaration exists, otherwise waits, and returns either one synchronous disposer or a synchronous iterable of disposers. Iterable effects install transactionally: a later setup failure disposes every earlier yielded effect in reverse order. + +The ledger records a declaration epoch distinct from the slot's ordinary entry version. An epoch changes whenever a child declaration is created or collapsed. Injection remembers the active epoch, disposes its callback effect when that epoch ends, and reruns the callback for a replacement declaration even when the final observed state is continuously declared. Ordinary contribution changes do not restart injection. + +Both sides retain their natural ownership. The injection controller and every contribution run on the contributing plugin's caller `Context`, so disposing that plugin removes its wait and active entries. The slot ledger's existing child-collapse cascade removes entries when the declarer disappears; injection then runs their disposers to release service-layer resources and remains ready for a later declaration. The declaring plugin's `Context` is neither retained as a capability source nor exposed to contributors. + +Dynamic reload code uses an ordinary Cordis plugin fiber as its replacement unit: activate the new module through `ctx.plugin()`, dispose and await the old fiber before mounting its replacement, and let its `slots.inject` and `slots.register` effects leave with that fiber. Renderer subscriptions observe the ledger removal and unmount the component; no slot-owned fiber tree is required. + +## Failure and lifecycle contract + +An injection whose declaration already exists reports callback setup failures synchronously. A callback failure after a delayed declaration first unsubscribes and rolls back its collected effects, then reports the failure outside the slot notification flush so one registrant cannot starve other listeners. Direct `slots.register()` into an undeclared slot continues to throw: injection is explicit and does not weaken load-time validation. + +Disposing an injection is idempotent. It unsubscribes before releasing the active callback effect, preventing teardown-triggered ledger notifications from resurrecting the contribution. Declaration-bound teardown is synchronous with the ledger boundary, so it releases service-layer resources before any subsequent same-tick registration. A waiting injection disposed with its plugin cannot activate later. + +## Alternatives considered + +**Use `ConversationService` or another service as an ordering barrier.** Service presence does not identify the declaration or follow its reload lifetime, and it creates a false package dependency for presentation-only contributors. + +**Bridge each declaration into a `slot:` Cordis service.** This pollutes the service namespace, turns a misspelled dynamic key into a silent service wait, and disguises ledger state as a business capability. Native slot injection provides the same wait without changing Cordis topology. + +**Create a Cordis context or fiber for every slot.** A contributor needs the intersection of its own plugin lifetime and the declaration lifetime, not the declarer's capabilities. A slot-owned context introduces capability inheritance and dual-parent teardown problems without improving ledger ownership. + +**Make `register()` wait implicitly.** Immediate failure on an undeclared target is a valuable configuration check. Explicit injection distinguishes an intentional independently ordered contribution from a broken composition. + +**Judge replacement from `spec(name) !== undefined` alone.** Collapse and redeclaration can batch into one continuously present final state while the old contributions have already been removed. The declaration epoch preserves that boundary. + +## Consequences + +Slot dependencies become auditable at the registration site and follow declaration replacement without package-specific ordering conventions. Dynamic plugin disposal removes rendered entries through existing Cordis effects, while declaration replacement has a stable hook for later slot-level HMR. + +The runtime carries one additional monotonic epoch per touched slot and injection callbacks must return their cleanup. Multi-registration callbacks use iterable effects so setup and teardown remain atomic. The flat dotted-key ledger and the single `register()` composition authority remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md new file mode 100644 index 0000000000..385cab875b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md @@ -0,0 +1,45 @@ +# Agent Note(agent 决策记录):slot 声明注入与重载生命周期 + +Status: implemented + +[English](2026-08-05-slot-declaration-injection.md) | 中文 + +## 问题 + +客户端插件可能在声明某个 slot 的插件之前或之后向该 slot 贡献内容。Cordis 服务注入无法表达这种依赖:服务只能作为间接的顺序信号;客户端 manifest(元数据清单)的依赖项不会规定激活顺序;即使所有相关服务始终挂载,slot 仍可能消失后重新出现。因此,立即注册会与尚未声明的 slot 形成竞态,而等待无关服务则会耦合本可独立重载的功能。 + +slot 级热替换还要求两个相互独立的所有者。移除声明方插件必须移除其子 slot 下的所有贡献;移除贡献方插件只能移除该插件自己的条目。即使消失与重新出现合并在同一次通知中,同一个 key 的替换声明也属于新的生命周期。 + +## 决策 + +`SlotsService.inject(name, callback)` 以已声明的 slot 本身作为依赖。完整的 `SlotMap` key 会经过静态检查;系统不引入命名空间构建器、合成的 Cordis 服务或 slot 专属 `Context`。声明存在时回调同步执行,否则等待;回调返回一个同步 disposer,或由多个 disposer 构成的同步 iterable。iterable effect 的安装具有事务性:后续 setup 失败时,系统会按逆序 dispose(资源释放)之前 yield 的所有 effect。 + +该账本记录独立于 slot 普通条目版本的 declaration epoch(声明代次)。每当子声明创建或折叠时,epoch 都会变化。注入会记住活跃 epoch;该 epoch 结束时,注入会 dispose 其回调 effect;即使最终观测到的状态始终为已声明,也会为替换声明重新执行回调。普通贡献变更不会重启注入。 + +声明方与贡献方各自保留其自然所有权。注入控制器和每项贡献都运行在贡献方插件调用时的 `Context` 上,因此 dispose 该插件会同时移除其等待与活跃条目。slot 账本现有的子项折叠级联会在声明方消失时移除条目;随后,注入会运行其 disposer 以释放服务层资源,并继续等待后续声明。系统既不会将声明方插件的 `Context` 保留为 capability 来源,也不会向贡献方公开它。 + +动态重载代码使用普通 Cordis 插件 fiber 作为替换单元:通过 `ctx.plugin()` 激活新模块;挂载替换模块之前,先 dispose 并等待旧 fiber;该 fiber 的 `slots.inject` 与 `slots.register` effect 会随之退出。renderer 订阅会观察到账本移除并卸载组件;无需建立 slot 自有的 fiber 树。 + +## 失败与生命周期契约 + +如果注入创建时声明已经存在,回调 setup 失败会同步上报。延迟声明出现后发生的回调失败,会先取消订阅并回滚已收集的 effect,再在 slot 通知刷新之外上报,避免一个注册方使其他 listener 得不到执行机会。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常:注入是显式机制,不会削弱加载时验证。 + +对注入执行 dispose 具有幂等性。它会先取消订阅,再释放活跃的回调 effect,避免拆卸触发的账本通知复活该项贡献。声明绑定的 teardown 与账本边界同步,因此会在同一 tick 的任何后续注册之前释放服务层资源。随插件一同 dispose 的待命注入无法在之后激活。 + +## 备选方案 + +**将 `ConversationService` 或其他服务用作顺序屏障。** 服务存在并不能标识相应声明,也不会跟随声明的重载生命周期;只负责呈现的贡献方还会因此产生虚假的包(package)依赖。 + +**将每项声明桥接为 `slot:` Cordis 服务。** 这会污染服务命名空间,使拼错的动态 key 变成静默的服务等待,并把账本状态伪装成业务 capability。原生 slot 注入无需改变 Cordis 拓扑,即可提供同样的等待能力。 + +**为每个 slot 创建 Cordis 上下文或 fiber。** 贡献方需要的是自身插件生命周期与声明生命周期的交集,而不是声明方的 capability。slot 所有的上下文会引入 capability 继承和双父级拆卸问题,却无法改善账本所有权。 + +**让 `register()` 隐式等待。** 对未声明目标立即失败是一项有价值的配置检查。显式注入能够区分有意独立排序的贡献与错误组合。 + +**只根据 `spec(name) !== undefined` 判断替换。** 折叠与重新声明可以合并成一个最终状态始终存在的通知,而旧贡献此时已经被移除。declaration epoch 保留了这条生命周期边界。 + +## 影响 + +slot 依赖可以在注册点审计,并且无需特定于包的顺序约定即可跟随声明替换。动态插件 dispose 会通过既有 Cordis effect 移除已渲染条目,而声明替换则为后续 slot 级 HMR(热模块替换)提供稳定钩子。 + +运行时为每个被访问的 slot 多维护一个单调 epoch,且注入回调必须返回清理操作。多注册回调使用 iterable effect,使 setup 与 teardown 保持原子性。扁平的点分 key 账本和唯一的 `register()` 组合权威保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml index dbd259b67a..6214a34d9c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md 2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be -2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9 +2026-07-19-windows-atomic-write-dacl-preservation.zh.md: d31b52b0a99240a1e4449427438e1e187b1b98da diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md index fc6ec5232c..d31b52b0a9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md @@ -28,4 +28,4 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成 ## 影响 -替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。 +替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index a7a00fd548..bdb4632744 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.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/bug-fix/2026-07-20-error-cause-chain-diagnostics.md 2026-07-20-error-cause-chain-diagnostics.md: 7de1f4f631cec90048ccc8eab7a6654560d84846 -2026-07-20-error-cause-chain-diagnostics.zh.md: 74820e80f729343a833c926adc6187b7cc9fc372 +2026-07-20-error-cause-chain-diagnostics.zh.md: 6ce642eb4a18a1a412fb63c3bb00042a921f4da3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 74820e80f7..6ce642eb4a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -1,37 +1,37 @@ -# Agent Note: 在每个诊断接缝处渲染错误 cause 链 +# Agent Note: 在每个诊断界面渲染错误 cause 链 Status: implemented [English](2026-07-20-error-cause-chain-diagnostics.md) | 中文 -## Problem +## 问题 TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: -1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断接缝都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 +1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断界面都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 -## Decision +## 决策 -- `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 -- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。 -- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断消费者自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 -- `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 +- `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是用于诊断界面的渲染器;路由仍然基于 `HarnessError.code`。 +- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并将原始拒绝值作为 `cause` 串入错误链。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,agent loop(智能体循环)仍将该轮次归类为取消而非恢复。 +- 每个诊断界面改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断消费方自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 +- `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。通过声明合并扩展出的未知 kind 按普通 turn 结束处理。 -`errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 +`errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费方都已导入的叶子包,共享不增加新的依赖边。 -## Alternatives considered +## 考虑过的替代方案 -**在每个错误的构造函数里渲染链(把 cause 烤进 `message`)。** 否决:当消费者同时遍历 `cause` 时会双重渲染(适配器修复的第一版产出了 `… fetch failed: bad port: fetch failed: bad port`),并且破坏了想按内层错误路由的消费者所需的结构化链。 +**在每个错误的构造函数里渲染链(把 cause 写入 `message`)。** 否决:当消费方同时遍历 `cause` 时会双重渲染(适配器修复的第一版产出了 `… fetch failed: bad port: fetch failed: bad port`),并且破坏了想按内层错误路由的消费方所需的结构化链。 -**只做一个感知 `cause` 的日志导出器。** 否决:持久化的 `turn/end` reason 和 TUI 通知不是日志行;被掩盖的消息会留在会话日志——回合内失败的唯一持久记录——以及主要 UI 表面里。 +**只做一个感知 `cause` 的日志导出器。** 否决:持久化的 `turn/end` reason 和 TUI 通知不是日志行;被掩盖的消息会留在会话日志——轮次内失败的唯一持久记录——以及主要 UI 表面里。 **逐包升级 `renderThrown`。** 否决:三个包已经各自持有几乎相同的私有副本;分别升级只会固化共享渲染器所要消除的重复。 -## Consequences +## 后果 -- 传输失败现在在 TUI 通知、readline transcript 和持久化会话日志里显示为 `DeepSeek API request to failed: fetch failed: connect ECONNREFUSED …`,代价是更长的诊断字符串。 -- 持久化的 `turn/end` 错误消息包含 cause 细节。现有 snapshot fixture 字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。 -- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: `),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些接缝上,链细节被判断为比类名更有价值。 -- `dsh-stdio` 对失败回合的输出不再沉默;解析 transcript 的管道消费者会看到新的 `[turn …]` 行。 +- 传输失败现在在 TUI 通知、readline transcript(文本记录)和持久化会话日志里显示为 `DeepSeek API request to failed: fetch failed: connect ECONNREFUSED …`,代价是更长的诊断字符串。 +- 持久化的 `turn/end` 错误消息包含 cause 细节。现有快照 fixture(测试前置数据)字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。 +- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: `),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断界面上,链细节被判断为比类名更有价值。 +- `dsh-stdio` 对失败轮次的输出不再沉默;解析 transcript 的管道消费方会看到新的 `[turn …]` 行。 - `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread`、`cli-demo` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml index 7782ea3360..89d49a5d34 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md 2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617 -2026-07-20-jsonl-storage-identity.zh.md: d7ba5c646a7adaaa0ebd60fac7b9c2f030361ff9 +2026-07-20-jsonl-storage-identity.zh.md: 6beb0d9f92ac1b1f4c3b03a783aa67e16b5fa7bb diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md index d7ba5c646a..6beb0d9f92 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md @@ -14,7 +14,7 @@ JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日 协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。 -如果配置的 JSONL 根目录已存在,插件加载时该路径必须是可读目录。根目录不存在仍然是有效配置,首次物化时会创建该目录。后端对每个会话只支持一个活动写入方;在所有者完成资源释放且所有写入停止之前,另一个后端实例或进程不得变更该会话。 +如果配置的 JSONL 根目录已存在,插件加载时该路径必须是可读目录。根目录不存在仍然是有效配置,首次物化时会创建该目录。后端对每个会话只支持一个活动写入方;在所有者完成 dispose(资源释放)且所有写入停止之前,另一个后端实例或进程不得变更该会话。 ## 考虑过的替代方案 @@ -22,7 +22,7 @@ JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日 **通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。 -**协调多个活动写入方。** 专用协调服务、进程级全局注册表或跨进程锁会定义新的部署拓扑,而不是修复身份验证。受支持的拓扑只有一个活动写入方;禁止覆盖的硬链接发布仍会裁决初始的同 id 创建竞争。 +**协调多个活动写入方。** 专用协调服务、进程级全局注册表或跨进程锁会定义新的部署拓扑,而不是修复身份验证。受支持的拓扑只有一个活动写入方;禁止覆盖的硬链接发布仍会裁决初始的同 id 创建竞态。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml index 663ad725d4..b8ab8490d0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md 2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8 -2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: edf9de6fe5388d75612946bfb05c4383d1856102 +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: f31c33e680b3db60a6fb758a522addca63224fea diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md index edf9de6fe5..f31c33e680 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -1,14 +1,14 @@ -# Agent Note: 摘要调用回放对话前缀以复用 KV 缓存 +# Agent Note: 摘要调用回放对话前缀以复用 KV Cache Status: implemented [English](2026-07-21-compaction-summary-prefix-cache-reuse.md) | 中文 -## Problem +## 问题 -自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + 派生历史)预热了提供方的 KV Cache 之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 -## Decision +## 决策 摘要指令从请求的**前端**(一个全新的 `system` 提示词)移到对话的**末尾**(最后一条 `user` 消息)。辅助调用现在逐字复现最后一个已路由请求的前缀,并追加一条尾部指令,因此它是已预热请求的真正前缀扩展,提供方会复用已缓存的 token。 @@ -20,25 +20,25 @@ Status: implemented `COMPACTION_INSTRUCTION` 以 "You are now acting as a compaction engine…" 开头,指示模型浓缩*上方的对话*。它保留先前检查点的结构化标题,并在其新位置上新增了两条前置系统提示词此前不需要的规则:不要提及摘要请求,以及只输出检查点文本而不调用任何工具。被遮蔽区域总是结束在工具配对平衡的边界上,因此在其后追加一条 `user` 消息,对 OpenAI 兼容适配器和 DeepSeek 适配器而言是合法的消息排序。 -### 缓存复用是尽力而为,正确性不是 +### 缓存复用是尽力而为,正确性则有保证 自动压缩总是锚定在表层头部,因此被遮蔽区域就是已路由请求的头部,回放的前缀与之完全匹配,这就是保证命中的情形。手动的中段 `compactRegion` 仍然回放真实的前缀并保持正确,但会放弃复用,因为它的被遮蔽区域不是请求头部。配置的 `summarizationProvider`/`summarizationModel` 若与对话的路由不同,也会放弃复用;这是部署方明确的权衡,而非缺陷。目标解析(配置的覆盖值 → 最新的已路由 header → agent(智能体)选项,否则抛出)保持不变。 -## Alternatives considered +## 考虑过的替代方案 - **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 - **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 - **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 -## Consequences +## 后果 - **`dsh-compact-basic`** 拥有 `SummarizationInput`;受保护的 `summarize(input, agent, signal?)` 钩子签名发生变化(发布前可接受),并且 `region.ts` 新增了 `buildSummarizationInput`,它在 header 前缀之后对被遮蔽的 seq 折叠 `deriveEventMessage`。 - **移除无用的渲染表面。** 旧的拍平路径(`renderTranscript` / `renderContentBlocks` 及其在 `dsh-compact` 中的 spec)已无消费方,连同其导出一并删除。 -- **README 的 Model Experience** 现在把 `dsh-compact-basic` 的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV 缓存效果记述为复用已预热的对话前缀。 +- **README 的 Model Experience** 现在把 `dsh-compact-basic` 的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV Cache 效果记述为复用已预热的对话前缀。 - **带框架的检查点输出未改变**,因此落地的 `user/message` 和每个对话请求快照都不受影响;只有辅助请求的形状发生了变化。 -## Testing +## 测试 - **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 - **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml index 0f80e5ff22..3021149a93 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-21-semantic-session-checkpoints.md -2026-07-21-semantic-session-checkpoints.md: 927a4c5d6d2aad5dea460ea29f97c1686e9d5398 -2026-07-21-semantic-session-checkpoints.zh.md: 6454b496aa8c03c172d6a4bc969e43e8dbca2430 +2026-07-21-semantic-session-checkpoints.md: 697d878dccbfab1ded9f73e134d594ba6f0665e1 +2026-07-21-semantic-session-checkpoints.zh.md: c6184b0b00db16f9e318f4cbd148b1f0525f03a5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md index 927a4c5d6d..697d878dcc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -10,9 +10,9 @@ Persistence buffered every synchronous `session/event` until the loop's final tu ## Decision -`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. At `agent/step`, it flushes pending prompt input or the preceding response/result batch before the next request is derived. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. The loop's final `turn/end` checkpoint remains the closing boundary and settles before another queued turn or idle observation. +`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. At `agent/pre-step`, it flushes pending prompt input or the preceding response/result batch before the next request is derived. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. The loop's final `turn/end` checkpoint remains the closing boundary and settles before another queued turn or idle observation. -Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/step` listeners precede this checkpoint; prompt input and the preceding loop-owned assistant message and ordered results are already in the log. +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/pre-step` listeners precede this checkpoint; prompt input and the preceding loop-owned assistant message and ordered results are already in the log. Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected between-step checkpoint closes the turn before another model request. A rejected final turn checkpoint is reported live and does not prevent later queued work. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md index 6454b496aa..c6184b0b00 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。 +`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/pre-step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。 -持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。 +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/pre-step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。 -检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤间检查点被拒绝时,系统会在发起下一个模型请求前结束该轮次。轮次的最终检查点被拒绝时,系统会实时报告该失败,但不会阻止后续排队工作。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 +检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤间检查点被拒绝时,系统会在发起下一个模型请求前结束该轮次。轮次的最终检查点被拒绝时,系统会实时报告该失败,但不会阻止后续排队工作。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序列。 -ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 +ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 完全停稳,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 -崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。 +崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定如何处理有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺副作用恰好执行一次这一通用保证。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml index 567f8e8c17..4a4e6d71cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md 2026-07-22-pi-ai-transport-truncation-classification.md: 119200a788c0b0521f385f4cf4e6adf05a0512f9 -2026-07-22-pi-ai-transport-truncation-classification.zh.md: 6a1bb478a86fc6ab726968b3df5752e0ad7fc9e6 +2026-07-22-pi-ai-transport-truncation-classification.zh.md: 0625d6041ccaeaff9d0d6f659c7b3856ab985ef6 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md index 6a1bb478a8..0625d6041c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md @@ -4,13 +4,13 @@ Status: implemented [English](2026-07-22-pi-ai-transport-truncation-classification.md) | 中文 -## Problem +## 问题 -一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 `terminated` 通知,而一个被截断的 Anthropic 响应则浮现出 `Anthropic stream ended before message_stop`。两者都是传输层截断——连接在提供方的终止 SSE 事件之前就已断开——然而 `dsh-llm-pi-ai` 中的 `classifyPiAiError` 对两者都不匹配,最终落入兜底的 `PI_AI_ERROR`。由于 `PI_AI_ERROR` 不在 `llm-retry` 的 `DEFAULT_RETRYABLE_CODES`(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`)中,一次可恢复的断开被当作永久性失败处理,从未被重试。 +一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 `terminated` 通知,而一个被截断的 Anthropic 响应则浮现出 `Anthropic stream ended before message_stop`。两者都是传输层截断——连接在提供方的终止 SSE(Server-Sent Events)事件之前就已断开——然而 `dsh-llm-pi-ai` 中的 `classifyPiAiError` 对两者都不匹配,最终落入兜底的 `PI_AI_ERROR`。由于 `PI_AI_ERROR` 不在 `llm-retry` 的 `DEFAULT_RETRYABLE_CODES`(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`)中,一次可恢复的断开被当作永久性失败处理,从未被重试。 -细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 把可操作的 `SocketError` 携带在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 +细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 将可据以采取行动的 `SocketError` 放在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 -## Decision +## 决策 - `classifyPiAiError` 识别另外两种传输层措辞,并将两者都映射为 `TRANSPORT`: - 流式输出中途的套接字断开,呈现为裸的 `terminated`(undici)或 `Premature close`(Node 流层); @@ -20,7 +20,7 @@ Status: implemented 分类仍然基于消息文本,因为那是 pi-ai 唯一交付的信号;`XXX` 标明它是一个权宜之计,而非期望的最终状态。 -## Alternatives considered +## 考虑过的替代方案 **通过 pi-ai 的 fetch/dispatcher/client 钩子捕获 `cause`。** 否决:pi-ai 0.81.1 一个都没暴露。`StreamOptions` 只提供 `onPayload`/`onResponse`;`onResponse` 在响应体流被消费之前触发,因此无法观察到流式输出中途的断开。Anthropic 路径接受一个 `client` 对象,但为拦截传输错误而为每个请求构造并注入一个提供方 SDK client,只为一个诊断字符串就越过了适配器的服务边界。 @@ -28,7 +28,7 @@ Status: implemented **在适配器里把扁平化后的错误包装成 `LlmError('TRANSPORT', { cause })`,仿照 DeepSeek 适配器。** 在此否决:DeepSeek 适配器包装的是拿到响应之前的 `fetch` 拒绝,其 `cause` 仍然完好,因此链式包装保留了真实细节。而在 pi-ai 路径中,终止事件的 `errorMessage` 已经是一个没有 `cause` 可链的扁平化字符串,因此包装只会加一层却恢复不了任何东西;分类出 code 是唯一还能增加的价值。 -## Consequences +## 后果 - 流式输出中途的传输层断开和终止前的流截断现在都携带 `TRANSPORT`,因此组合出的 `llm-retry` 策略会默认重试它们,而不是让该轮次失败。 - 通知文本不变(`terminated` / `Anthropic stream ended before message_stop`):cause 细节在适配器看到之前就已丢失,因此 `errorChain` 没有更多内容可渲染。只有被路由的 `code` 得到了改善。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml index 3bd5fa6be2..22149319f3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md 2026-07-24-empty-model-response-is-retryable.md: 3ecb106fc3a53070f66d1de351120aaf99d4d0de -2026-07-24-empty-model-response-is-retryable.zh.md: 91ce4105ebe60b71f12667ccf28ea905566353d5 +2026-07-24-empty-model-response-is-retryable.zh.md: 2573ef66d9ca1e6c0610686d0051c738774af588 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md index 91ce4105eb..2573ef66d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md @@ -1,36 +1,36 @@ -# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures +# Agent Note: 空模型补全是可重试的 EMPTY_RESPONSE 失败 Status: implemented [English](2026-07-24-empty-model-response-is-retryable.md) | 中文 -## Problem +## 问题 -提供方偶尔会返回一种退化的 completion:流本身格式完好,携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning(推理)、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个轮次,却没有取得任何进展。 +提供方偶尔会返回一种退化的 completion:流本身格式完好,以终止性的 `stop` 结束,却没有任何内容块——没有文本、没有推理(reasoning)、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个 Round,却没有取得任何进展。 -## Decision +## 决策 由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题: - `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。 - `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。 -- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。 -- 提供方拥有的 normal 重试默认策略包含 `EMPTY_RESPONSE`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除,而 `dsh-llm-retry` 会执行解析后的策略。 +- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含推理的流算作有内容,仍视为成功。 +- 由提供方定义的常规重试默认值包含 `EMPTY_RESPONSE`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除,而 `dsh-llm-retry` 会执行解析后的策略。 检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。 这套归类使用既有的主循环机制——`finishError` → `agent/request-error` → `dsh-llm-retry`——并让 `agent-loop` 保持提供方无关。重试预算耗尽时,该轮次会以显式的 `EMPTY_RESPONSE` 失败结束,而不是在没有内容的情况下成功结束。 -## Alternatives considered +## 考虑过的替代方案 **在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。 **在 `llm/stream` waterfall(瀑布式事件)上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。 -**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。 +**把仅含空白或仅含推理的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在推理之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。 -## Consequences +## 后果 - 一个偶发异常的提供方会消耗一次有界重试,而不是一个没有输出的轮次;一个持续返回空内容的模型则会暴露为用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。 - 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。 -- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次干净的已完成轮次。 +- `empty-response-retry` ACP(Agent Client Protocol)快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次正常完成的轮次。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml index cae5b75cb4..06d4078f76 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md 2026-07-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31 -2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: f6f0a8c0e393bc19f24085b04db06056369bb329 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md index 214a5ef924..f6f0a8c0e3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -10,7 +10,7 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 ## 决策 -`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使在一次 `Session.run()` 结束后仍然存续,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 `Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 @@ -26,4 +26,4 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 ## 后果 -高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。 +高层消费方会按协议传输顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层委派、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 2979127ed1..712d3e2dd8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md 2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d -2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 73e3e7614c1eab814080eb7c5e0d03322f1c7145 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index 845d9ee883..73e3e7614c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 +提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时头部和底部操作按钮始终可达。但当 composer 的可用区域变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 缺陷不在这个高度上限,而在高度不足时由谁来吸收。`.options` 是一个 `flex-direction: column` 的盒子,其子元素默认取 `flex-shrink: 1`,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 `min-height: 42px`,而 `.optionCopy` 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 `align-items: center`,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 `.options` 报告的 `scrollHeight` 等于 `clientHeight`,因此始终不会给出滚动条。 @@ -26,25 +26,25 @@ Status: implemented **把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。 -**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。 +**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时头部和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。 **把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 ## 后果 -- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 +- 被压缩的 composer 会滚动其选项列表,而不是让选项行互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 - 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 -- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 +- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,较矮的可用区域现在会显示滚动条。 - 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 ## 验证 -Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 +Web e2e 的 composer 场景会在三个受挤压的可用区域高度下(900x520/440/380),在实际运行的 composer 上断言该不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明可用区域确实受到了高度上限约束)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。 -该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 +该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模块包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。 -`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 +`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新预期输出,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index f2d73ddf1f..8c3c24a722 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md 2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 8ba35f4bbc2a4b408a01e11f09bd767860964875 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 55302b509e..8ba35f4bbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -10,9 +10,9 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 -复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。 +复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用归一化字符串复用。 对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml index 56832b37e2..8c8b97c103 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.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/bug-fix/2026-07-28-load-pre-identity-session-messages.md 2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41 -2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3 +2026-07-28-load-pre-identity-session-messages.zh.md: 210064f6e7c1ea239da89c4542346e164c48e274 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md index 61d57ac9f3..210064f6e7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`。 +带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的标头仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造活跃的 `Session`。 -消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 +消息表示改变时没有提升版本,导致这些日志无法仅凭标头与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 ## 决策 -`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 +`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入用 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 -同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 +同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其活跃会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将活跃会话的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。 @@ -28,9 +28,9 @@ Status: implemented ## 后果 -消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 +消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 -这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 +这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享的协调器契约会在内存参考实现、JSONL 和 SQLite 后端上验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 ## 相关 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml index 3df46dedf2..987b40abff 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.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/bug-fix/2026-07-28-web-conversation-polish-sweep.md 2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a -2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e +2026-07-28-web-conversation-polish-sweep.zh.md: 0f352f066da13a749f61e89f52dd20487f7726b1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md index d19a5f7593..0f352f066d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -1,37 +1,37 @@ -# Agent Note: Web conversation UI polish sweep +# Agent Note: Web 对话 UI 视觉优化 Status: implemented [English](2026-07-28-web-conversation-polish-sweep.md) | 中文 -## Problem +## 问题 一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。 -## Decision +## 决策 -本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。 +本次修复仅改动展示层;不会有任何内容进入会话日志。 - **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。 - **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。 - **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。 - **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。 -- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。 +- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,尚待确认的选择所对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。 - **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。 -- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。 +- **标题文字改为 14/20,标题栏去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。 -## Alternatives considered +## 考虑过的替代方案 - **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。 - **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。 -- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。 +- **在每个工具各自的 presenter 中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。 - **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。 - **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。 -## Consequences +## 后果 聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。 -## Testing +## 测试 -`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。 +`chat-view.spec.tsx` 锁定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 锁定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 1349e9fd47..cbc9924c07 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 4d8d66ea625d02f098da906ae58af1a2e5e41819 -2026-07-29-human-transcript-append-origin.zh.md: d4c1c4a83d868af5e27d7bda2a22827cf9d6c5b1 +2026-07-29-human-transcript-append-origin.md: 9804d3d8d67a4ad00c3d395c2081fd47e03c254a +2026-07-29-human-transcript-append-origin.zh.md: 909f0756dd29c9deddb840a188eb19a773f92d29 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 4d8d66ea62..9804d3d8d6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -6,7 +6,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md) ## Problem -The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message`, `assistant/message`, and `steering/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only provenance and the replacement that cites it. +The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only provenance and the replacement that cites it. Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index d4c1c4a83d..909f0756dd 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message`、`assistant/message` 和 `steering/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。 +终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。 日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 3c6718b975..3df507fc7c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.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/bug-fix/2026-07-31-resume-selector-batch-projection.md 2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662 -2026-07-31-resume-selector-batch-projection.zh.md: 10333ea7cc5e7f2051c37f3374c5dc061bd0586e +2026-07-31-resume-selector-batch-projection.zh.md: 3f824a3063849306e5c1c21699f6a19eefcfefe5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 10333ea7cc..3f824a3063 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -22,11 +22,11 @@ session-query 与 session-persistence 的任何表面都未改变。随附的 TU ## Alternatives considered -**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。 +**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开 seam 已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。 **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。 +**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。 **专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml new file mode 100644 index 0000000000..c4a8fd6268 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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-04-composer-tab-gutter-reservation.md +2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034 +2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md new file mode 100644 index 0000000000..3b28c35c1f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -0,0 +1,50 @@ +# Agent Note: The conversation column reserves one scrollbar gutter for every view + +Status: implemented + +English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md) + +## Problem + +The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown. + +In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces. + +So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn. + +## Decision + +`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`. + +The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. + +The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. + +The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column. + +The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason. + +## Alternatives considered + +**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is. + +**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent. + +**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one. + +**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it. + +## Consequences + +- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column. +- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn. +- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. +- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's. + +## Testing + +`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish. + +The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped. + +The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md new file mode 100644 index 0000000000..26e8b6bff7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 会话列为每个视图预留同一条滚动条槽 + +Status: implemented + +[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文 + +## 问题 + +composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。 + +在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。 + +于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。 + +## 决策 + +`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。 + +这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 + +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 + +横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 + +这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 + +## 曾考虑的替代方案 + +**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。 + +**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。 + +**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。 + +**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。 + +## 后果 + +- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 +- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。 +- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 +- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 + +## 测试 + +`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。 + +该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。 + +随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml new file mode 100644 index 0000000000..02ea7ca431 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.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-04-load-pre-react-loop-sessions.md +2026-08-04-load-pre-react-loop-sessions.md: b7dcad1ff7fe0aa8239ac03f52b50dfa55417515 +2026-08-04-load-pre-react-loop-sessions.zh.md: 14c6dcc7b4da016ebef424a66fae0b96fb469cd4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md new file mode 100644 index 0000000000..b7dcad1ff7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md @@ -0,0 +1,40 @@ +# Agent Note: Load sessions from the pre-react-loop format + +Status: implemented + +English | [中文](2026-08-04-load-pre-react-loop-sessions.zh.md) + +## Problem + +The react-loop simplification changed durable events while retaining `SESSION_FORMAT_VERSION` 0. Stored sessions from the change's base contain `steering/message` and `turn/start.trigger`; their terminal reasons also use coarse `aborted`, separate `disposed`, and two older error payloads. Current surface and turn invariants cannot replay those records directly. + +The new durable inbox is not part of this compatibility problem. The base emitted process-local inbox notifications but no `agent/inbox/*` session events, so replaying old history as pending work would resurrect already claimed or discarded prompts. + +## Decision + +`PersistenceCoordinator` recognizes the exact pre-react-loop shapes after backend decoding and projects them into the current read view. It removes the obsolete `turn/start.trigger`, converts `steering/message` to the same identified `user/message`, maps old failure facts into the current structured error, folds `disposed` into an aborted turn with the `disposed` cause, and represents coarse aborted records with the persistence-only `{ kind: 'legacy' }` cause because their caller is unavailable. + +The coordinator applies the projection to `load`, `inspect`, adoption, HMR prefix comparison, and `readFrom`. A seek-capable `readFrom` normally reads only its suffix; when that suffix contains a legacy event needing an earlier replacement identity, the coordinator loads and normalizes the complete prefix before returning the requested seq range. + +The importer does not synthesize inbox splices. A resumed pre-react-loop agent begins with empty pending lists, matching the base runtime's inability to persist pending inbox work. The stored artifact remains append-only and later events use the current format. + +## Alternatives considered + +**Treat the same-version records as unsupported.** This follows the pre-release default but strands sessions produced by the PR base even though the removed steering content and terminal facts have complete mappings. + +**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work. + +**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would manufacture provenance. A dedicated `legacy` cause keeps the stop classification without making a false audit claim. + +**Rewrite stored JSONL and SQLite records.** A rewrite would violate the append-only contract and require backend-specific atomic migration machinery for a read compatibility boundary. + +## Consequences + +Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory, JSONL, and SQLite `load`/`inspect`/`readFrom`, including the SQLite suffix fallback; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. + +This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity. + +## Related + +- [Load sessions persisted before message identity](2026-07-28-load-pre-identity-session-messages.md) — owns deterministic identities and the general read-only import boundary for another same-version format change. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns append-only backend storage and resume. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md new file mode 100644 index 0000000000..14c6dcc7b4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 加载 react-loop 重构前格式的会话 + +Status: implemented + +[English](2026-08-04-load-pre-react-loop-sessions.md) | 中文 + +## 问题 + +react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持久事件。该变更基线所存储的会话包含 steering(中途引导)事件 `steering/message` 和 `turn/start.trigger`;其终止原因还使用粗粒度 `aborted`、独立的 `disposed` 和两种旧版错误载荷。当前 surface 和轮次不变量无法直接回放这些记录。 + +新的持久 inbox 不属于此兼容性问题。该基线会发出进程本地 inbox 通知,但不会产生 `agent/inbox/*` 会话事件,因此将旧历史回放为待处理工作会让已经领取或丢弃的提示词再次执行。 + +## 决策 + +`PersistenceCoordinator` 会在后端解码后识别 react-loop 重构前的确切形状,并将其投影为当前读取视图。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。 + +协调器会把该投影应用于 `load`、`inspect`、接管、HMR 前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。 + +导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent 从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。 + +## 考虑过的替代方案 + +**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR 基线产生的会话无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。 + +**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。 + +**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会虚构来源。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。 + +**重写已存储的 JSONL 和 SQLite 记录。** 重写会违反仅追加契约,并要求为读取兼容边界建立后端专用的原子迁移机制。 + +## 后果 + +以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器契约覆盖内存、JSONL 和 SQLite 的 `load`/`inspect`/`readFrom`,包括 SQLite 后缀回退;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 + +此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。 + +## 相关资料 + +- [加载消息标识机制引入前持久化的会话](2026-07-28-load-pre-identity-session-messages.md):负责另一项同版本格式变更的确定性标识和通用只读导入边界。 +- [以抽象服务实现会话持久化](../architecture/2026-06-14-session-persistence.md):负责仅追加后端存储和恢复。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml new file mode 100644 index 0000000000..3d9fef5b34 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.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-05-context-meter-blind-to-compaction.md +2026-08-05-context-meter-blind-to-compaction.md: ab39ae4e109f238960fd60de5e5b61075344f525 +2026-08-05-context-meter-blind-to-compaction.zh.md: c93aa509530e48acf906b18ba85bab4c7d355d69 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md new file mode 100644 index 0000000000..ab39ae4e10 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -0,0 +1,46 @@ +# Agent Note: the context meter could not see a compaction + +Status: implemented + +English | [中文](2026-08-05-context-meter-blind-to-compaction.zh.md) + +## Problem + +The composer's [context meter](../feature/2026-08-05-composer-context-meter-breakdown.md) took its ring, percentage, and `~used / capacity` header from `contextPressure.pressureTokens`, the newest provider-reported prompt size. That number moves only when a request reports usage, and compaction reports none: `compact-basic` summarizes through a direct `ctx.llm.stream()` call and appends `compact/start`, `compact/summary`, the replacement `user/message`, and `compact/end` — no `assistant/message`, no usage chunk. + +So the meter was frozen across the one action taken to change it. Driving a real `compactNow` through the agent loop: + +``` +BEFORE compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 4365] +AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 286] +``` + +The composition rows, which fold the surface, dropped by 93%. The ring — the primary affordance, and the reason a user opens the panel right after compacting — did not move at all, and would not until an entire further turn completed. The panel then showed a header and rows disagreeing by more than an order of magnitude, at exactly the moment a reader was most likely to add the rows up. + +## Decision + +`contextPressure` publishes a second numerator, `projectedTokens`: the provider sample plus the heuristic repricing of everything the surface gained or lost since that sample was taken, clamped at zero. The fold carries the priced surface through the shared `surface-fold.ts` and stamps `sampledSurfaceTokens` when a usage sample lands — **before** the same event joins the surface, so an `assistant/message` anchors against the surface its own request actually carried. `stateVersion` moves to 3. + +Only the delta is estimated. The anchor stays provider-exact, which keeps the estimator's systematic CJK and JSON-schema underpricing out of the occupancy figure while still letting the number react the moment content lands or a span is shadowed. `contextOccupancy` reads `projectedTokens` and falls back to the bare sample, so a projection restored from a pre-field checkpoint degrades to the old behavior instead of vanishing. + +This reverses the "the ring, header, and bar length stay provider-exact" half of the [context meter decision](../feature/2026-08-05-composer-context-meter-breakdown.md). What that decision was protecting — not fabricating precision by scaling heuristic rows to a provider total — is preserved: the rows are still unscaled, and the header still does not equal their sum. What changed is the recognition that "provider-exact but describing a request two compactions ago" is not the more truthful figure. + +## Alternatives considered + +**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk provenance (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. + +**Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display. + +**Let the UI subtract, exposing `sampledSurfaceTokens` and reading `contextBreakdown.messageTokens`.** Splits one figure's arithmetic across two projections and the client. The host owns the vocabulary; it should publish the whole value. + +## Consequences + +Occupancy now advances with every surface event rather than once per turn, so the ring creeps up as a turn produces tool results instead of jumping at its end — and drops the instant a compaction lands. That is more projection frames on the wire: one per surface event for `contextPressure`, the rate `contextBreakdown` already ran at. + +The panel's composition rows still do not sum to the header, and now for one clearly-stated reason instead of two: the rows carry the estimator's error, the header's anchor does not. The remaining lever is estimator accuracy (CJK-aware weighting in `estimate.ts`), which changes no seam. + +`sampledSurfaceTokens` assumes nothing joins the surface between a step's request and its usage report. The loop admits steering and context before `buildRequest` and drains tool results after `assistant/message`, so that holds; if it ever stops holding, the error is bounded by one message and self-corrects at the next sample. + +## Testing + +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats-bash-sample.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md new file mode 100644 index 0000000000..c93aa50953 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -0,0 +1,46 @@ +# Agent Note:上下文仪表看不见压缩 + +Status: implemented + +[English](2026-08-05-context-meter-blind-to-compaction.md) | 中文 + +## 问题 + +composer 的[上下文仪表](../feature/2026-08-05-composer-context-meter-breakdown.md)的圆环、百分比与 `~已用 / 容量` 标题都取自 `contextPressure.pressureTokens`,即提供方报告的最新提示词规模。这个数字只在某个请求报告用量时才会移动,而压缩不报告用量:`compact-basic` 通过直连的 `ctx.llm.stream()` 调用生成摘要,只追加 `compact/start`、`compact/summary`、用作替换的 `user/message` 和 `compact/end`——没有 `assistant/message`,也没有用量分片。 + +于是在唯一一个专门用来改变它的操作面前,这块仪表纹丝不动。通过真实 agent loop 驱动一次 `compactNow`: + +``` +BEFORE compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 4365] +AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 286] +``` + +折叠表层得出的组成明细行下降了 93%。而圆环——那个主要的可操作元素,也正是用户压缩完立刻会去点开面板的理由——完全没动,而且要等到又跑完一整轮才会动。此时面板上的标题与明细行相差一个数量级以上,恰恰发生在读者最可能去把明细行加总的时刻。 + +## 决策 + +`contextPressure` 发布第二个分子 `projectedTokens`:在提供方样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零。该折叠通过共享的 `surface-fold.ts` 携带已计价的表层,并在用量样本落地时记下 `sampledSurfaceTokens`——记录时机在同一条事件加入表层**之前**,因此 `assistant/message` 锚定的正是它自己那次请求实际携带的表层。`stateVersion` 提升到 3。 + +只有增量部分是估算的。锚点保持提供方精确值,从而把估算器对 CJK 文本与 JSON schema 的系统性低估挡在占用率数字之外,同时又让这个数字能在内容落地或某段区间被遮蔽的瞬间做出反应。`contextOccupancy` 读取 `projectedTokens`,并回退到裸样本,因此从不含该字段的检查点恢复出来的投影会退化为旧行为,而不是直接消失。 + +这推翻了[上下文仪表决策](../feature/2026-08-05-composer-context-meter-breakdown.md)中「圆环、标题与进度条总长保持提供方精确值」的那一半。那条决策真正想守住的东西——不要把启发式明细行按比例缩放到提供方总量、从而伪造精度——依然守住了:明细行仍未被缩放,标题仍不等于它们之和。改变的是这样一个认识:「提供方精确、但描述的是两次压缩之前那个请求」并不是更真实的数字。 + +## 备选方案 + +**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对分片来源的随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 + +**在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。 + +**让 UI 自己做减法:暴露 `sampledSurfaceTokens`,再读 `contextBreakdown.messageTokens`。** 这会把一个数字的算术拆散到两个投影和客户端三处。词汇的所有者是宿主,就应当由它发布完整值。 + +## 影响 + +占用率现在随每个表层事件推进,而不再是每轮跳一次,因此一轮中产生工具结果时圆环会持续爬升,而不是等到轮次结束才跳变——压缩落地的瞬间它也会掉下来。代价是线路上多了投影帧:`contextPressure` 每个表层事件推一帧,也就是 `contextBreakdown` 本来就在跑的频率。 + +面板的组成明细行仍然加不出标题数字,但现在只剩一个能讲清楚的原因,而不是两个:明细行带着估算器的误差,标题的锚点不带。剩下的抓手是估算精度(在 `estimate.ts` 里做 CJK 感知加权),它不改动任何 seam。 + +`sampledSurfaceTokens` 依赖一个前提:在某个步骤的请求与它的用量报告之间,不会有新内容加入表层。循环在 `buildRequest` 之前接纳 steering 与 context,在 `assistant/message` 之后才排空工具结果,因此该前提成立;即便将来不再成立,误差也被限制在一条消息以内,并在下一个样本处自行纠正。 + +## 测试 + +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats-bash-sample.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml new file mode 100644 index 0000000000..7737670466 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.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-05-workspace-blank-session-reuse-membership.md +2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md new file mode 100644 index 0000000000..910a10e9ad --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -0,0 +1,29 @@ +# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions + +Status: implemented + +English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md) + +## Problem + +Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there. + +Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run. + +## Decision + +The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists". + +## Alternatives considered + +**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself. + +**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface. + +## Consequences + +Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. Membership is a new condition on the reuse scan, and it has one observable stale-mirror edge: in the window where the session mirror is fresh but the Workspace account frame lags, the Workspace's own member blank can fail the membership check and a duplicate blank is minted where the old code reused — a second `New Session` row under that Workspace rather than the old failure shape (a session that no grouping surface shows). Both windows are transient and the per-Workspace coalescing still prevents duplicate creates racing one another. No host, wire, or durable-format change. + +## Testing + +`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md new file mode 100644 index 0000000000..7e7aa899f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -0,0 +1,29 @@ +# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话 + +状态:已实现 + +[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文 + +## 问题 + +在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。 + +根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。 + +## 决定 + +复用扫描现在要求工作区成员关系:`blank` 且 `summary.cwd === workspace.path` 且 `workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。 + +## 曾考虑的替代方案 + +**收养游离会话而不是新建。** 让 `session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。 + +**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。 + +## 后果 + +游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。 + +## 测试 + +`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index d4665517a8..369f69973d 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-14-acp-multi-session.md 2026-06-14-acp-multi-session.md: 088fe984fbc94fe0d8654702d5fce9eb0581cd3b -2026-06-14-acp-multi-session.zh.md: 2a803a6eff9b16b48fb90b2b986f74e762e5419c +2026-06-14-acp-multi-session.zh.md: 083b160aa51e669b74931654cd08acaa3d2aa221 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md index 2a803a6eff..083b160aa5 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -36,14 +36,14 @@ ACP 桥接层将活跃会话存储在 `Map` 中。agen **每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理。 -**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的会话 token 才是跨边界的标识,应当在插件重载后仍然存活。 +**以 agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的会话 token 才是跨边界的标识,应当在插件重载后仍然存活。 ## 后果 N 个会话可以并发地返回已提交的回答、提交提示词、请求权限和运行后台任务,而不会交错或跨会话结算。一个会话中的取消不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 -桥接层不暴露独立关闭单个活跃会话的协议方法。所有记录在连接拆除时一起离开;会话导航与恢复属于 host API,而非这个自动化协议。 +桥接层不暴露独立关闭单个活跃会话的协议方法。所有记录会在连接拆除时一并移除;会话导航与恢复属于 host API,而非这个自动化协议。 ## 验证 -多会话测试套件通过按路由投递的已提交回答、独立的进行中提示词、定向取消以及共享拆除来驱动并发会话;审批与输出边界套件覆盖权限路由和精确 agent 拒绝。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 +多会话测试套件通过按路由投递的已提交回答、独立的进行中提示词、定向取消以及共享拆除来驱动并发会话;审批与输出边界套件覆盖权限路由和对非同一 agent 对象的拒绝。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 8773a797e9..04a4c91430 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.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/feature/2026-06-15-code-mode.md 2026-06-15-code-mode.md: b6a24ecd9700e32912b8112b59cbd8b6ab131eb5 -2026-06-15-code-mode.zh.md: 4d0a4cf8fa31cf9d9954e5bd95f823dfc0668444 +2026-06-15-code-mode.zh.md: a00a43ece1e581190de6096be8138df25a23f07f diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 4d0a4cf8fa..a00a43ece1 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -19,7 +19,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: 1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 -2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。 +2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包`@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-code-mode-typed-tool-returns.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。 @@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 ### run_code 工具与分发桥 -在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: +在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: 1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生契约的分发池(调度设计由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 @@ -61,7 +61,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 `packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service`(`super(ctx, 'codeRuntime')`)加上词汇: - `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` -- `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与解析值可以完整跨越实现的序列化边界。 +- `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与返回值可以完整跨越实现的序列化边界。 - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill,并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject;消费方仍在自己的错误边界处理不合规后端的拒绝。 - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。 - 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 @@ -70,18 +70,18 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 ### worker-thread 运行时 -`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包(package)。每次 `run()`: +`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包。每次 `run()`: -1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。 +1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且会保留源码位置,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。 2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。 3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。Code Mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 4. **通过消息端口桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker,堆退出或外层溢出会作为显式失败报告。 -6. **dispose 至完全停稳**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。 +6. **dispose(资源释放)至完全停稳**:服务自身的 dispose 终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。 ### 信任姿态 -worker 运行时提供的是隔离,而非安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 +worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 ### 模型看到的内容 @@ -104,7 +104,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw **`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 -**在原生工具调用上做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 +**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 **循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和每工具绑定并行化一起解锁。 @@ -114,20 +114,20 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw **SDK 中的清洁化标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`。 -**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。在 MVP 中否决:跨调用状态对会话日志不可见,破坏了「每个请求是日志的纯函数」这一可重建性保证;每次 run 全新保持了这一点。内核风格的后端在未来仍可通过同一 seam 表达,配合自己的日志方案。 +**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。在 MVP 中否决:跨调用状态对会话日志不可见,破坏了「每个请求是日志的纯函数」这一可重建性保证;每次 run 均使用全新实例则维持了这一保证。内核风格的后端在未来仍可通过同一 seam 表达,配合自己的日志方案。 ## 风险 -**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,隔离程度超过它,门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。 +**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,约束能力强于它,门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。 **`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 **SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 共同约束了这一增长:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 -**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 解析值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 +**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 **仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。 -**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)对恶意程序是承重的。两侧都有单元测试(带 pending 诱饵分发的热循环在 `computeMs` 处死亡;在慢绑定上空闲的程序存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。 +**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 `computeMs` 预算时终止;等待慢速绑定的空闲程序则会持续运行至 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index 60a0b50006..27336b9c4b 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md 2026-06-17-filesystem-tool-schemas.md: 9941b3916b361a916c8148eb099eb8cfd46371c8 -2026-06-17-filesystem-tool-schemas.zh.md: 47e43c47db83b1fcc292b17cf0113d9abd9293d1 +2026-06-17-filesystem-tool-schemas.zh.md: 4de0fe000a5524a3c5e76c6fa67f8a8b7e37ac6d diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index 47e43c47db..4de0fe000a 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[文件系统能力 seam Agent Note](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包(package)拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Note 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 +[文件系统能力 seam Agent Note](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的已观测文件/陈旧版本策略——[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Note 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 Agent Note 为原型选择最小的共有接口。 @@ -49,7 +49,7 @@ schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string` - `file_path: string`——必填。要写入的路径,由 `ctx.fs` 解析。 - `content: string`——必填。要写入的完整 UTF-8 文本内容。 -在默认 fs-policy 下,使用 `write` 更新已有文件需要同一执行上下文先前对该文件有过一次观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。 +在默认 fs-policy 下,使用 `write` 更新已有文件需要同一执行上下文先前对该文件有过一次观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的陈旧版本防护提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。 schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面向模型的参数暴露。陈旧版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。 @@ -64,7 +64,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 - `new_string: string`——必填。字面替换文本;空字符串表示删除匹配内容。 - `replace_all?: boolean`——可选。默认为 false。为 false 时,`old_string` 必须恰好匹配一处。 -`edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。 +`edit` 要求同一执行上下文先前观测过该文件(任何窗口化的 read 都算——授权取决于观测到的版本是否仍为最新,而不要求查看全文),或该上下文先前对该文件执行过 write/edit。`dsh-fs-policy` 策略插件推导所有者,并将记录的版本作为陈旧版本防护提供;提供方的变更锁会强制执行该防护。 首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和陈旧版本的语义。 @@ -80,7 +80,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 | `write` | 创建/更新操作、目标显示路径、新文件版本 | 简洁的创建/更新成功文本 | | `edit` | 替换次数、全量替换标记、目标显示路径、新文件版本 | 简洁的编辑成功文本 | -结构化结果不会重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分。 +结构化结果不会重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。以节省 token 为目的的截断属于模型投影的职责,而非后端规范结果的一部分。 ## 延后事项 @@ -105,8 +105,8 @@ schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒 ## 后果 -**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 Agent Note 或聚焦的后续工作形式到来,而非对初始 schema 的重载。 +**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 Agent Note 或聚焦的后续工作形式到来,而不是让初始 schema 承载过多内容。 -**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 +**v1 中没有显式的面向模型的陈旧版本防护。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 -**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 Agent Note 预先选择 snake_case,并将其视为稳定的面向模型的契约。 +**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会导致提示词、示例和下游客户端随之改动。本 Agent Note 预先选择 snake_case,并将其视为稳定的面向模型的契约。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 714b75d942..d337e7e0e3 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-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/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: ef37313bc6fb984689793fa5a3e7ac4d9238ea88 -2026-06-18-compaction-capability-seam.zh.md: 9f123a8c40f303a2635af78cafd34de448e27e03 +2026-06-18-compaction-capability-seam.md: 27dbde9f2349681cf47c4d25b16399b26ed9e1ca +2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index ef37313bc6..27dbde9f23 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -10,7 +10,7 @@ A long-running agent conversation grows without bound. As the event log accumula The [session surface](../architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. -Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler and Session's always-on append/seed boundary reject `surfaceOp` on it. +Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed to the message-producing event types (`user/message`, `assistant/message`, `tool/result`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler and Session's always-on append/seed boundary reject `surfaceOp` on it. ## Decision @@ -19,7 +19,7 @@ Two forces shape the design. First, compaction policy and reusable token measure Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, the manual failure taxonomy, and the canonical checkpoint message source. It declares `compactIfNeeded()`, `compactNow()`, and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, pre-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. 3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. 4. **Human consumer** — `@deepseek-ai/dsh-command-compact` registers argument-free `/compact` through `ctx.commands` and calls the backend-independent `compactNow()` operation. It is direct human control, not a model-facing tool. @@ -33,18 +33,18 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making all three operations abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` reserves idle turn admission and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. ### Automatic pressure runs after successful durable step work -Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure. +Successful-call pressure runs at the next `agent/pre-step`, after the preceding response, tool results, buffered context, and steering are durable and before the next request is derived. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure. Canonical provider context overflow takes a separate path. The failed step closes and `agent/request-error` receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and returns `{ kind: 'retry' }` only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` -assistant/message → tool/result/context/steering -await serial agent/post-step ⟵ pressure compaction inside the successful step -step/end +assistant/message → tool/result/context/steering → step/end +claim the next batch → await waterfall agent/pre-step ⟵ pressure compaction before the next request +enter → next step/start provider overflow → step/end await waterfall agent/request-error ⟵ forced compaction between attempts @@ -53,7 +53,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface ### Retention is turn-agnostic; tool-pairing balance is the only structural guard -Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. +Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The next pre-step check can compact early closed tool pairs before continuation opens another step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. `compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches the resolved retained-token budget and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. @@ -96,7 +96,7 @@ The `compact/start … compact/end` bracket is justified by two roles: 1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. 2. **Prevents concurrent compaction.** Every automatic, manual, and explicit-range entry point refuses a live unmatched `compact/start`. The bracket is the single lock; no process-local mutex duplicates it. -The lock excludes another compaction, not unrelated facts. Its markers are time points rather than an exclusive container, so idle injected context may appear between a standalone manual start and end. Automatic work requires whole-surface stability inside its turn. Manual work revalidates only the selected positional span, letting append-only context outside it remain visible after replacement. +The lock excludes another compaction, not unrelated facts. Its markers are time points rather than an exclusive container, so durable inbox splices may appear between a standalone manual start and end. Automatic work requires whole-surface stability inside its turn. Manual work revalidates only the selected positional span, letting append-only context outside it remain visible after replacement. The lifecycle boundary makes crash state unambiguous: @@ -111,7 +111,7 @@ The lifecycle boundary makes crash state unambiguous: ## Alternatives considered - **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. All three operations are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook. -- **Compaction on `agent/request` or provisional `agent/pre-step` inputs** — rejected because neither proves the final durable request and both couple generic lifecycle to compaction-specific envelope data. Post-step replay plus canonical overflow recovery covers both successful and rejected calls. +- **Compaction on `agent/request` or a compaction-specific loop callback** — rejected because the former observes a provisional request and the latter couples generic lifecycle to compaction policy. Pre-step replay of the prior durable request plus canonical overflow recovery covers successful and rejected calls. - **A `compact` boolean or untyped request metadata map** — rejected because multiple auxiliary call kinds would become mutually exclusive flags, while an open bag would discard compiler-checked vocabulary. One typed `purpose` discriminant extends with additional call kinds without adding another `GenerateOptions` field. - **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling. - **Teaching core turn-repair about `compact/*`** — rejected: the general end-seed boundary already distinguishes prior-lifecycle history, and patching core for every future `xxx/start … xxx/end` pair is exactly the coupling the capability-seam architecture exists to avoid. @@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous: ## Consequences - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently. -- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. +- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations. @@ -128,7 +128,7 @@ The lifecycle boundary makes crash state unambiguous: ## Testing - **Unit:** Real Loader and invariant plugins cover whole-unit retention, pruning configuration and replay, rich-block ordering, metadata preservation, convergence, both `compact/end` outcomes, open-tail refusal, pruning-only and summarized overflow recovery, generation proof, caps, and original-error preservation. -- **Loop:** Tests pin post-step after durable tool results and before `step/end`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. -- **Manual:** Admission, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. +- **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. +- **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. - **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 9f123a8c40..1fe9ece286 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -10,16 +10,16 @@ Status: implemented [会话接口面](../architecture/2026-06-18-session-surface.md)正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 记录溯源信息以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 -两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM 系列的 [`ctx.tokenMeter` 服务](../architecture/2026-07-15-replay-token-meter-service.md)所有,摘要生成则可以使用模型调用、模板或远程服务。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 `surfaceOp`。 +两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM 系列的 [`ctx.tokenMeter` 服务](../architecture/2026-07-15-replay-token-meter-service.md)所有,摘要生成则可以使用模型调用、模板或远程服务。第二,`SurfaceEventType` 封闭为产生消息的事件类型(`user/message`、`assistant/message`、`tool/result`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 `surfaceOp`。 ## 决策 ### 压缩是一个能力 seam,接口与实现分离 -遵循[能力 seam Agent Note(agent 决策记录)](../architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: +遵循[能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md),压缩以独立包发布,使契约、算法和(后续的)消费方 surface 各自独立演进: 1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇、`compact/*` 会话事件、手动失败分类体系以及规范的检查点消息来源。它将 `compactIfNeeded()`、`compactNow()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 -2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,消费 `ctx.tokenMeter`,并拥有尾→头保留遍历、通过 `ctx.llm.stream()` 生成摘要、surface 替换、锁、步骤后压力处理和规范的上下文溢出恢复。`summarize()` 是其唯一的子类钩子;计价与回放仍归 meter 所有。 +2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,消费 `ctx.tokenMeter`,并拥有尾→头保留遍历、通过 `ctx.llm.stream()` 生成摘要、surface 替换、锁、步骤前压力处理和规范的上下文溢出恢复。`summarize()` 是其唯一的子类钩子;计价与回放仍归 meter 所有。 3. **无模型配套服务** — `@deepseek-ai/dsh-compact-tool-result-prune`:一个具体的可选服务,在后端选择摘要范围之前,重写当前过大的 `tool/result` 节点。它不是第二种压缩实现,也不实现 `CompactService`。 4. **面向用户的消费方** — `@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 注册无参数 `/compact`,并调用后端无关的 `compactNow()` 操作。它是供用户直接控制的命令,不是面向模型的工具。 @@ -33,18 +33,18 @@ Status: implemented 早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 -`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 会预留空闲轮次接纳,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 +`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 ### 成功的持久步骤工作完成后运行自动压力检查 -成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 +成功调用的压力检查在下一个 `agent/pre-step` 运行;此时前一响应、工具结果、缓冲上下文与 steering 已经持久化,而下一个请求尚未派生。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误。compact-basic 自行持有按 agent 计的溢出次数,在强制执行一次有效且平衡的缩减前先修剪,且仅当 `session.surface.replaceGeneration` 增加时才返回 `{ kind: 'retry' }`;这包括没有摘要范围时仅修剪取得的进展。随后循环关闭失败轮次,开启新的编号重试轮次,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 ``` -assistant/message → tool/result/context/steering -await serial agent/post-step ⟵ pressure compaction inside the successful step -step/end +assistant/message → tool/result/context/steering → step/end +claim the next batch → await waterfall agent/pre-step ⟵ pressure compaction before the next request +enter → next step/start provider overflow → step/end await waterfall agent/request-error ⟵ forced compaction between attempts @@ -53,7 +53,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface ### 保留是轮次无关的;工具配对平衡是唯一的结构守卫 -自动压缩在**每个成功的**步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 会在一轮之内增长。步骤后检查可以在后续步骤开启前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。 +自动压缩在**每个成功的**步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 会在一轮之内增长。下一个 pre-step 检查可以在继续执行打开另一步骤之前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。 `compactIfNeeded` 保留估算大小达到解析后保留 token 预算的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`dsh-compact` 导出前后边缘辅助函数;只要 `replaceGeneration` 不变,其逐会话缓存就只折叠新增的 surface 尾部节点,面对仅日志增长时不读取事件,并在替换后重建当前成员关系与平衡。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。 @@ -96,7 +96,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab 1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。 2. **防止并发压缩。** 每个自动、手动和显式范围入口点都会拒绝活动的未匹配 `compact/start`。该标记对就是唯一的锁;没有进程本地 mutex 重复承担同一职责。 -该锁只排除另一项压缩,不排除无关事实。其标记是时间点,而不是排他的容器,因此空闲注入的上下文可以出现在独立手动 start 与 end 之间。自动工作要求其轮次内的整个 surface 保持稳定。手动工作只重新验证所选位置 span,使其外部的仅追加上下文在替换后保持可见。 +该锁只排除另一项压缩,不排除无关事实。其标记是时间点,而不是排他的容器,因此持久 inbox splice 可以出现在独立手动 start 与 end 之间。自动工作要求其轮次内的整个 surface 保持稳定。手动工作只重新验证所选位置 span,使其外部的仅追加上下文在替换后保持可见。 生命周期边界使崩溃状态含义明确: @@ -111,7 +111,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 曾考虑的替代方案 - **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。三个操作都是抽象的;可复用测量属于单独的 LLM 系列服务,`summarize()` 是 basic 唯一的钩子。 -- **在 `agent/request` 或临时 `agent/pre-step` 输入上执行压缩**——否决,因为两者都无法证明最终的持久请求,而且都会将通用生命周期耦合到压缩专属的信封数据。步骤后回放与规范溢出恢复同时覆盖成功和被拒绝的调用。 +- **在 `agent/request` 或压缩专属的 loop 回调上执行压缩**——否决,因为前者观察的是临时请求,后者会将通用生命周期耦合到压缩策略。对先前持久请求进行 pre-step 回放,再加上规范溢出恢复,即可覆盖成功和被拒绝的调用。 - **`compact` 布尔值或无类型的请求元数据 map**——否决,因为多个辅助调用种类会变成互斥标志,而开放 map 会丢弃由编译器检查的词汇。一个类型化的 `purpose` 判别字段可以扩展其他调用种类,而无需再为 `GenerateOptions` 添加字段。 - **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。 - **教导核心轮次修复识别 `compact/*`**——否决:通用 end-seed 边界已经能够区分先前生命周期的历史;为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。 @@ -119,7 +119,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 后果 - **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact`。`packages/llm/token-meter` 独立拥有回放感知的测量。 -- **自动 seam**:`agent/post-step`(`@mode serial`)处理成功调用的压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。通用 `agent/pre-step` 保持为四参数检查点,不携带压缩专属的提示词/前缀 payload。 +- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 @@ -128,7 +128,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 测试 - **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、`compact/end` 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。 -- **循环测试:** 测试固定步骤后处理发生在持久工具结果之后、`step/end` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 -- **手动测试:** 无需模型密钥即可固定接纳、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 +- **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 +- **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 - **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 774b9cc52e..bab63f5149 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.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/feature/2026-06-22-acp-subagent-backend.md 2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8 -2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38 +2026-06-22-acp-subagent-backend.zh.md: ba9d4255723367ab4afc5ab87e056f12f5c3a285 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 61359246b0..ba9d425572 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -10,7 +10,7 @@ subagent seam([seam Agent Note](2026-06-21-subagent-capability-seam.md))的 ## 决策 -`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个派生的子进程中,并以 ACP *客户端*身份驱动它。它是现有服务端桥接 `@deepseek-ai/dsh-acp`(ACP *agent*)的方向反转孪生体:桥接应答 `initialize`/`newSession`/`prompt`;本后端调用它们并实现 `Client` 回调(`sessionUpdate`、`requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信。 +`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个通过 spawn 启动的子进程中,并以 ACP *客户端*身份驱动它。它是现有服务端桥接 `@deepseek-ai/dsh-acp`(ACP *agent*)的方向反转孪生体:桥接应答 `initialize`/`newSession`/`prompt`;本后端调用它们并实现 `Client` 回调(`sessionUpdate`、`requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信。 ### 每次运行启动全新进程 @@ -18,7 +18,7 @@ subagent seam([seam Agent Note](2026-06-21-subagent-capability-seam.md))的 ### 最小化客户端桩 -客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam Agent Note 所述。 +客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个表示允许的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam Agent Note 所述。 ### 无启动时能力 @@ -26,19 +26,19 @@ subagent seam([seam Agent Note](2026-06-21-subagent-capability-seam.md))的 ### 工作区 cwd 解析 -子进程工作目录来自显式解析,绝不使用 harness 进程的 cwd:若已配置部署 `cwd` 覆盖,则相对于启动目录将其转为绝对路径并在加载时验证;否则使用父会话 header 的 cwd 并在启动时验证;如果两者都不存在,则在生成任何进程前大声拒绝。一个 ACP 服务端进程会服务来自多个工作区的会话,因此 `process.cwd()` 不能代替会话工作区——旧的隐式回退会让子进程在服务端启动目录中运行。候选路径必须是 harness 可以进入的绝对目录(要求 `X_OK`;仅 `statSync().isDirectory()` 会接受 mode-600 的目录,而 spawn 会因 EACCES 失败);解析出的同一路径同时用作子进程 cwd 与 ACP `session/new` 工作区。 +子进程工作目录来自显式解析,绝不使用 harness 进程的 cwd:若已配置部署 `cwd` 覆盖,则相对于启动目录将其转为绝对路径并在加载时验证;否则使用父会话 header 的 cwd 并在启动时验证;如果两者都不存在,则在 spawn 任何进程前明确拒绝。一个 ACP 服务端进程会服务来自多个工作区的会话,因此 `process.cwd()` 不能代替会话工作区——旧的隐式回退会让子进程在服务端启动目录中运行。候选路径必须是 harness 可以进入的绝对目录(要求 `X_OK`;仅 `statSync().isDirectory()` 会接受 mode-600 的目录,而 spawn 会因 EACCES 失败);解析出的同一路径同时用作子进程 cwd 与 ACP `session/new` 工作区。 ### StopReason 映射 -ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义,任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 在子 agent 级别失败时从不 reject。 +ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义,任务未完成)、未知→`error`。spawn/传输/RPC 失败时,结果为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 在子 agent 级别失败时从不 reject。 ### 安全:清洗子进程环境 -子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|PASSWORD|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 +子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|PASSWORD|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到 spawn 启动的进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令的结果为 `error` 而非以未处理错误崩溃父进程。 ## 测试 -- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试提示词/输出流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、会话前竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 +- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试提示词输入/输出流程、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、会话前竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 - **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。 - **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。 - **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..5f045324dd 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: df7ae58f8a42a8c1aac8113a429ef0f50b2fb795 +2026-06-24-workspace-context.zh.md: aecc57a5c63b4c6282260863f4183944c14b5cf8 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..df7ae58f8a 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -28,9 +28,9 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe ### Baseline Injection -At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. +At the first `agent/pre-step` of an agent-loop instance, the plugin composes one sourced user-role baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so it becomes durable with the direct prompt and reaches the first request. Rejection or an empty first-step decision leaves the baseline in the next-step inbox for a later wakeup. The plugin loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads the configured candidates from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. -The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. +The baseline becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. If a previously queued workspace baseline is still pending, the plugin removes that exact message and prepends its replacement instead of accumulating duplicates. A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. @@ -68,7 +68,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. +**Always leave prepared workspace context in the inbox.** Rejected because context prepared during pre-step would then survive the current request and start a second model step by itself. The inbox remains the staging and rejection fallback, while an entering pre-step owns atomic delivery with its final batch. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..aecc57a5c6 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 @@ -28,9 +28,9 @@ Status: implemented ### 基线注入 -在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在 agent loop(智能体循环)实例的第一个 `agent/pre-step`,插件会组合一条带来源的 user 角色基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使其与直接提示词一同成为持久记录并抵达第一次请求。reject 或空的第一步决策会将基线留在 next-step inbox,等待后续唤醒。插件先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录加载已配置候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 -该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 +该基线会成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。若先前排队的 workspace 基线仍在等待,插件会删除该确切消息并 prepend 替代值,而不会累积副本。 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 @@ -66,9 +66,9 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, ## 考虑过的替代方案 -**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 +**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库自身拥有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 +**始终把准备好的 workspace 上下文留在 inbox 中。** 不予采纳,因为在 pre-step 中准备的上下文会因此在当前请求结束后继续留存,并自行启动第二个模型步骤。inbox 仍作为暂存区以及 reject 时的后备,而进入步骤的 pre-step 负责随最终批次原子投递。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 @@ -76,11 +76,11 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用模型总结文件。** 不予采纳,因为指令文件本身已经是经过整理的摘要;再执行一次模型调用既不确定,也可能抹掉边界情况要求。使用带字节预算的确定性全文更简单。 -## 影响 +## 后果 -工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的 session/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。 +工作区指引按会话隔离,并由 demo 入口、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的会话/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。 -仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 +仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该攻击面扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index eb39a7a1ee..0875df143d 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.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/feature/2026-06-29-todo-write-tool.md 2026-06-29-todo-write-tool.md: 288932f641a37c13ea6beeb069ac360c4a8447c1 -2026-06-29-todo-write-tool.zh.md: 03dae6328e5c7f4379694ab01db3434b4469826c +2026-06-29-todo-write-tool.zh.md: 2eced2e1670c20726989137d441d1c78df289641 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 03dae6328e..2eced2e167 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为交互式宿主提供实时进度清单。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。 +harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为交互式宿主提供实时进度清单。调研的所有参考编码 agent(智能体),包括 claude-code、opencode、codex、oh-my-pi 和 pi,都提供了某种形式的此功能;本 harness 此前没有。 ## 决策 @@ -18,15 +18,15 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 状态在会话日志上,而非服务 -列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从「其后没有更晚 `turn/start`」的最近一次 `todo/write` 重新推导站立计划([计划条生命周期](2026-07-28-todo-plan-clears-on-next-turn.md)),无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。) +列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从「其后没有更晚 `turn/start`」的最近一次 `todo/write` 重新推导当前计划([计划条生命周期](2026-07-28-todo-plan-clears-on-next-turn.md)),无需独立的持久化后端、无需重新恢复状态的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费方直接获得这份重建;web 客户端的分页窗口则从尾页 history 中由宿主计算的投影获得——见 [web todo 展示说明](2026-07-23-web-todo-display.md)。) ### 不是 surface 事件 -`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入有序 surface,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。) +`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入有序 surface,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个尚未结束的轮次内,而它始终如此:它在工具调用的步骤中途追加。) ### 相比 claude-code V1 舍弃的字段:`activeForm`、id、priority -claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识;不要 priority——它只曾是 ACP `PlanEntry` 的协议格式(wire format)要求,在 bridge 边界合成为常量而非建模,并已随该投影一起离开。每舍弃一个字段,模型每次调用就少产出一项。 +claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(以磁盘为后端、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识;不要 priority——它只曾是 ACP `PlanEntry` 的协议格式(wire format)要求,在 bridge 边界合成为常量而非建模,并已随该投影一起离开。每舍弃一个字段,模型每次调用就少产出一项。 ### 单一所有者——无集群机制(YAGNI) @@ -44,7 +44,7 @@ schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重 四个层级,预先设计: - **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR(热模块替换)安全性);以及 TUI 折叠。 -- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。 +- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——事故复盘(postmortem)0001)。 - **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。 - **恢复/回放**——持久化的 `todo/write` 折叠回当前任务列表。 - **带密钥 e2e + 快照**——真实提示词诱导 `todo_write`;组装后的快照固定日志事件和交互式渲染。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 809c14dedb..3b7a2c32a6 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe -2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 +2026-06-30-hook-bridges.md: a31116818c57e18d04e656f371efd1101a63b6af +2026-06-30-hook-bridges.zh.md: 8827408b67e2dd19f867a8658309f00231ba06c5 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 99c6b1941a..a31116818c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) ## Problem -The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). +The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. @@ -24,7 +24,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | Seam | CC | Codex | |---|---|---| | `agent/session-start` (emit) | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` | -| `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold | +| `agent/pre-step` | `deny`→`reject`; context-only→delegate+fold into `enter` | `block`→`reject`; context-only→delegate+fold into `enter` | | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | | `agent/turn-stopping` | blocking Stop → next-step steering | same | @@ -37,11 +37,11 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de Every bridge `inject()` and additional-context input explicitly passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `user/message.source` as the plugin rather than the user. -`UserPromptSubmit` runs during admission, before any turn opens. It therefore writes no turn-scoped `hook/invoked` / `hook/result` pair: a block leaves no transcript, while allowed additional context is durably represented by its sourced `user/message`. The Codex payload still receives the candidate next `turn_id`; rejection does not consume that number. +`UserPromptSubmit` runs at pre-step after `turn/start`, so every invocation writes its turn-scoped `hook/invoked` / `hook/result` pair. Rejection leaves the claimed input removed, closes the turn as blocked with no step, and retains the hook pair as its durable decision evidence. The Codex payload receives that open turn's `turn_id`. ### Adding context is not a veto — delegate, then prepend -A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to the downstream decision. Both seams carry ordered `additionalContexts` arrays, so the bridge prepends its separately sourced entry while preserving every downstream source, envelope, and metadata field; a downstream prompt block still drops all context because the prompt never reaches the model, while post-tool block semantics may explicitly retain contexts. Code Mode ferries the same array through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed and that retained prompt and post-tool contexts remain separate. +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including Code Mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. ### CLAUDE_PROJECT_DIR defaults to the session workspace diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 66855c3c4f..8827408b67 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -6,16 +6,16 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 -`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: +`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: -- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。负责构建 CC 形态的逐事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。 +- **`dsh-hooks-codex`**——Codex 当前钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 @@ -24,24 +24,24 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | Seam | CC | Codex | |---|---|---| | `agent/session-start`(emit) | additionalContext → `agent.inject()` | 纯 stdout 输出 → additionalContext → `agent.inject()` | -| `agent/prompt-submit` | `deny`→`block`;仅上下文→delegate+fold | `block`→`block`;仅上下文→delegate+fold | +| `agent/pre-step` | `deny`→`reject`;仅上下文→委托并折叠到 `enter` | `block`→`reject`;仅上下文→委托并折叠到 `enter` | | `tools/pre-execute` | `deny`→`deny`;`ask`→`ask` | `block`→`deny`(无 allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback;仅上下文→delegate+fold | 同上 | | `agent/turn-stopping` | 阻塞的 Stop → 下一步 steering(中途引导) | 同上 | | `subagent/start`(emit) | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 | | `subagent/end`(emit) | 仅观察 | 本桥接不支持 | -CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。ACP 自动化客户端可以应答所属会话的一次性机器策略请求,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。 +CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。ACP(Agent Client Protocol)自动化客户端可以应答所属会话的一次性机器策略请求,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。 ### 上下文来源始终是插件(误标签防护) -每个桥接的 `inject()` 和 additional-context 输入都显式传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `user/message.source` 为插件而非用户。 +每个桥接的 `inject()` 和 additional-context 输入都显式传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试固定验证结果中的 `user/message.source` 为插件而非用户。 -`UserPromptSubmit` 在准入阶段运行,早于任何轮次开启。因此它不写入任何轮次范围的 `hook/invoked` / `hook/result` 对:阻止不会留下 transcript(文本记录),而被允许的额外上下文由其带来源的 `user/message` 持久呈现。Codex payload 仍会收到候选的下一个 `turn_id`;拒绝不会消耗该编号。 +`UserPromptSubmit` 在 `turn/start` 之后的 pre-step 运行,因此每次调用都会写入轮次范围的 `hook/invoked` / `hook/result` 对。reject 会让已领取输入保持删除,将轮次关闭为 blocked 且不包含步骤,并保留该 hook 对作为持久决策证据。Codex payload 会收到这个已打开轮次的 `turn_id`。 ### 添加上下文不是否决——先 delegate,再 prepend -仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `allow`/`accept`,会短路其后的每个 `agent/prompt-submit` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游决策。两个 seam 都携带有序的 `additionalContexts` 数组,因此桥接会在保留所有下游来源、信封和元数据字段的同时,前置加入其独立来源的条目;下游提示词阻止仍会丢弃所有上下文,因为提示词从未到达模型,而工具后阻止语义可以显式保留上下文。Code Mode 会通过外层 `run_code` 结果转送同一数组。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:上下文钩子允许后,较晚的监听器仍能阻止提示词,且保留的提示词和工具后上下文仍彼此分离。 +仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 Code Mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 @@ -49,7 +49,7 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` ### 隔离 -配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不应拖垮 agent)。CC 桥接只运行 shell 形式的 `type: 'command'` 钩子;`http`、`mcp_tool`、`prompt` 和 `agent` 处理器被解析后跳过。Codex 桥接只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start`、`subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。 +配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非导致启动崩溃(一个拼错的路径不应拖垮 agent)。CC 桥接只运行 shell 形式的 `type: 'command'` 钩子;`http`、`mcp_tool`、`prompt` 和 `agent` 处理器被解析后跳过。Codex 桥接只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start`、`subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。 ### 钩子在哪里运行,配置从哪里来 @@ -61,7 +61,7 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` - **Stop 循环防护**(`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。 - **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;轮中请求会将停止请求记录在 `hook/result` 中,钩子在此期间保留其逐点效果(决策/上下文)。 - **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。 -- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达,需要一个 awaited 的启动 seam。 +- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行,不阻塞启动流程,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达,需要一个 awaited 的启动 seam。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 3ecd4e2dbe..4a599f3310 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 -2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb +2026-06-30-hook-protocol-lib.md: 2edff0d501cd7695873c68054eeb3a1e9942eced +2026-06-30-hook-protocol-lib.zh.md: 42e526fdf8073465d5f6faf99a6c92fbe09505f5 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index ce25f40e96..2edff0d501 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -21,7 +21,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. - **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and owner-defined execution relation stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. -**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). +**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PreStepDecision`, `ContinuationDecision`, `PostToolDecision`). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 062160931f..42e526fdf8 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -12,16 +12,16 @@ Status: implemented ## 决策 -在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 +在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它负责四类原语和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。 -- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符表示多个精确匹配备选项),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。 +- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(仅 CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有方定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 +- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有者定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还负责定义持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 -**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 +**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PreStepDecision`、`ContinuationDecision`、`PostToolDecision`)。 ## 曾考虑的替代方案 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 +每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 接线、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 5845297101..604255dee5 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-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/feature/2026-06-30-interception-seams.md -2026-06-30-interception-seams.md: 4658983e1f098ecd199eecec4408e7c2f134cbf7 -2026-06-30-interception-seams.zh.md: 13b7c56829412773111fcf6d75cc717c51d49c7b +2026-06-30-interception-seams.md: 629a1aed509bd9bce9a2da89ce84b17a1db8e6b6 +2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 4658983e1f..629a1aed50 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for one claimed queued message before the loop opens a turn or appends `user/message`. The explicit admission signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` discards the candidate without creating session history. +- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. @@ -35,7 +35,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Run prompt policy before opening the turn.** A blocked prompt creates no turn or durable event. On allow, the loop stages the rewritten prompt followed by every returned `additionalContexts` entry, opens the turn, and drains that outbox before the first step. Each claimed ordinary-send item is the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Run pre-step policy at every proposed step.** The loop opens the turn before the initial claim and decision, so rejection closes a durable blocked turn with no step or model-visible message. A tool continuation with no newly claimed input still submits an empty batch, allowing per-request context producers to add logged messages to that exact request. On enter, the loop opens the step and appends the returned batch as `user/message` events before request derivation. Each claimed follow-up remains the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). 2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but each context is a separate sourced `user/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. @@ -56,4 +56,4 @@ The seam package does **not** declare `hook/*` session events (the durable hook- ## Consequences -The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-turn prompt admission, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an admission rejection as `cancelled` after the agent becomes idle with no owned turn, while hook-driven snapshots verify the observable bridge behavior end to end. +The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-step claim settlement, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an initial pre-step rejection from its blocked no-step turn as `end_turn`, while hook-driven snapshots verify the observable bridge behavior end to end. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index 13b7c56829..d6958c9d1e 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**「原生钩子」不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell 钩子协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 -该表面需要为以下场景提供各自独立的契约:逐提示词策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md)提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。 +该表面需要为以下场景提供各自独立的契约:逐提示词策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。 ## 决策 @@ -16,28 +16,28 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **Agent 事件**(`dsh-agent`): - `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,针对一条取得所有权的排队消息触发,早于循环开启轮次或追加 `user/message`。显式准入 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会丢弃该候选消息,不产生会话历史。 +- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn`、`step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 -**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的面向模型的内容(steering,中途引导);循环随后重新读取 outbox,继续执行或关闭轮次。 +**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。 ### 工具流水线为每个阶段赋予一种权限 -每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → 由定义拥有的 `finalizeContent` → `tools/result`。注册表对调用方输入创建快照、实体化并冻结参数、分配一个不透明 token,并在策略开始前对可见定义的最终内容回调创建快照。嵌套调用仅携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。 +每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → 由工具定义负责的 `finalizeContent` → `tools/result`。注册表对调用方输入创建快照、实体化并冻结参数、分配一个不透明 token,并在策略开始前对可见定义的最终内容回调创建快照。嵌套调用仅携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。 -- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每个已解析的 decision 仍会到达后策略;抛出异常的监听器会成为最终的规范化失败。 +- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每个已解析的 decision 仍会到达后置策略;监听器抛出的异常会成为最终的规范化失败。 - **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 - **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前可以替换并恢复必需的 `exec.signal`,但不能移除它;包装层接收抛出异常或未知工具产生的、已完成规范化的规范成功/失败结果。包装层自行产生的成功结果会短路调度,并通过已解析的输出声明重新规范化。 -- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、替换呈现内容或规范值,或附加 `additionalContexts`。替换值会重新校验并重新计算呈现;替换内容会保留程序化值,且不构成保密边界。返回的 decision 是受支持的变换通道。 -- **`ToolDefinition.finalizeContent`** 是一个可选、同步、完备且仅能处理内容的边界,在调用创建时随可见定义一起被快照。注册表将候选结果规范化并创建无损快照后,它恰好运行一次;候选结果包括绕过后续 waterfall 的 pre、around 或 post 监听器失败,以及为另一个结果字段创建快照时发现的错误。它可以替换 `content`,也可返回 `undefined` 保留原内容,但不能重写 `isError`、结构化错误身份、上下文或呈现元数据。工具在此执行自身最后一道内容不变式,而无需将策略失败转换为更弱的阻止 decision。 -- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。 +- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、替换呈现内容或规范值,或附加 `additionalContexts`。替换值会重新校验并重新计算呈现;替换内容会保留程序化值,且不构成保密边界。返回的 decision 是受支持的变换通道。 +- **`ToolDefinition.finalizeContent`** 是一个可选、同步、对所有输入都有定义且仅能处理内容的边界,在调用创建时随可见定义一起被快照。注册表将候选结果规范化并创建无损快照后,它恰好运行一次;候选结果包括绕过后续 waterfall 的 pre、around 或 post 监听器失败,以及为另一个结果字段创建快照时发现的错误。它可以替换 `content`,也可返回 `undefined` 保留原内容,但不能重写 `isError`、结构化错误身份、上下文或呈现元数据。工具在此执行自身最后一道内容不变式,而无需将策略失败转换为更弱的阻止 decision。 +- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步且故障受控的通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。 -核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由定义拥有的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。 +核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由工具定义负责的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。 ### 三个承重的循环决策 -1. **在开启轮次之前运行提示词策略。** 被阻止的提示词不会创建轮次,也不产生持久事件。允许时,循环先暂存重写后的提示词,再暂存每个返回的 `additionalContexts` 条目,然后开启轮次并在第一个步骤之前排空该 outbox。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中唯一的直接提示词。 +1. **在每个拟议步骤运行 pre-step 策略。** 循环会在首次领取和决策之前打开轮次,因此 reject 会关闭一个持久、blocked 且不含步骤或模型可见消息的轮次。即使工具续步没有新取得所有权的输入,也会提交空批次,使逐请求上下文生产方可以把带日志的消息加入这一次请求。enter 时,循环先开启步骤,再把返回批次作为 `user/message` 追加,然后派生请求。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个已领取 follow-up 仍是其轮次中唯一的直接提示词。 -2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是一条独立的带来源 `user/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 +2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是一条独立的带来源 `user/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/工具结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,在所有已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 3. **stopping 监听器通过 steering 通道请求继续执行**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词。 @@ -56,4 +56,4 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 后果 -规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、轮次前的提示词准入、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接在 agent 空闲且不再拥有轮次后,将准入拒绝结算为 `cancelled`,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、pre-step 领取结算、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接会把 blocked 无步骤轮次中的首次 pre-step reject 结算为 `end_turn`,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index 5669911951..3e46be0e44 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-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/feature/2026-06-30-session-store-fork-api.md 2026-06-30-session-store-fork-api.md: 69ff85e1f137f4f263bf951af0a3f655411c606a -2026-06-30-session-store-fork-api.zh.md: 3304a6f384c9004b3572c95881f832a4aa21b77c +2026-06-30-session-store-fork-api.zh.md: 75262c8ceca40745feba1d4384cb949b95f3c677 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index 3304a6f384..75262c8cec 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -8,11 +8,11 @@ Status: implemented 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须连续,并在活跃轮次之外结束。如果在执行过程中 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了执行与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。已关闭轮次之后的独立上下文和插件所属纯日志事件是稳定且可 fork 的历史。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 +语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须连续,并在活跃轮次之外结束。如果在执行过程中 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了执行与提供方 transcript(文本记录)不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。已关闭轮次之后的独立上下文和由插件负责写入的纯日志事件是稳定且可 fork 的历史。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 ## 决策 -`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 的能力。不设独立的 `dsh-session-fork` 包(package),也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的会话存储和持久化后端。 +`dsh-session` 直接负责 `ctx.sessions` 上的常规活跃会话 fork。不设独立的 `dsh-session-fork` 包,也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的会话存储和持久化后端。 store 暴露一个操作: @@ -36,14 +36,14 @@ Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子 ## 曾考虑的替代方案 -**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。 +**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了能力 seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。 -**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。 +**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。 -**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并且静默丢弃了父轮次的尾部。 +**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并且静默丢弃了父轮次的尾部。 ## 后果 -公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 +公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话创建时便带有种子事件,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账契约,真实 Chromium 场景则固定组装后的消息操作与谱系树。 +v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才声明支持该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账契约,真实 Chromium 场景则固定组装后的消息操作与谱系树。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index cc2e199e60..15054d6ba1 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md 2026-07-05-dynamic-workflows.md: bba62098c66477a3f1929f9029e81c645bfc4d41 -2026-07-05-dynamic-workflows.zh.md: 6aa1ce63f0edf9dbf296d12d3bc0c62594fa33a6 +2026-07-05-dynamic-workflows.zh.md: 2005ca14883ae137148541273900c7f3e65769a5 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 6aa1ce63f0..2005ca1488 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -14,27 +14,27 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### 脚本契约(兼容 Claude Code) -一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。流水线各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。 +一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。流水线各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 结算为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制随日志机制一并延后实现,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。 与 CC 有一处刻意的严格性差异:钩子误用——未知或延迟的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——会抛出带 `fatal: true` 的 `WorkflowError`,组合器会重新抛出 fatal 错误而非将 item 置为 null。如果不这样做,一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——这正是本仓库禁止的「被接受后被忽略」的失败模式。另有一处新增:工具的 `args` 参数是一个 JSON 对象(裸列表被包装为一个字段),使协议格式(wire format)保持诚实。 ### seam(dsh-workflow) -`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`——每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出;返回的 `WorkflowRun` 的 `result` 永不 reject(失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit,携带数据快照(id + meta;`workflow/end` 省略 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇详情见 [core-data-structures/workflow.md](../../../../docs/core-data-structures/workflow.md)。 +`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`——每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出;返回的 `WorkflowRun` 的 `result` 永不 reject(失败时结算为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit,携带数据快照(id + meta;`workflow/end` 省略 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇详情见 [core-data-structures/workflow.md](../../../../docs/core-data-structures/workflow.md)。 ### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程 -**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后完全停稳;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。 +**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎会约束有缺陷脚本的影响,并保证结果已 settled、值可安全表示为 JSON、取消后完全停稳;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。 -**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而消息端口 RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 +**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限定了文档中说明的脚本接口范围,而消息端口 RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 -宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 完全停稳,在此协议上保持 subagent run 契约。这些竞态算法归[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records)所有。 +宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 完全停稳,在此协议上保持 subagent run 契约。这些竞态算法由 [agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 定义。 引擎暴露一条进程内 `MessageChannel` 测试路径,因为主进程 V8 覆盖率无法观测 worker 执行。 **Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam,仅做形状校验。宿主从不执行元数据字面量,否则脚本控制的访问器可以在 worker 隔离之外运行。 -**值边界**:`materializeFromRealm` 复制出站值,并拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数字。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器,因此 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`,脚本应基于 `name` 或 `code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和宽限限制均为经校验的配置。 +**值边界**:`materializeFromRealm` 复制出站值,并拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数字。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会明确报错。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用对所有输入均有定义的渲染器,因此 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`,脚本应基于 `name` 或 `code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和宽限限制均为经校验的配置。 ### 消费方(dsh-tool-workflow) @@ -46,35 +46,35 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 -`ObjectJsonSchema` 是 `dsh-tools` 统一且可强制执行的原始 JSON Schema 子集所提供的对象根消费方视图;不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[统一 JSON 值 schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义词汇与校验语义,[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes)则定义组装、提交、守卫和终止停止算法。 +`ObjectJsonSchema` 是 `dsh-tools` 统一且可强制执行的原始 JSON Schema 子集所提供的对象根消费方视图;不支持的关键字会明确报错,因为该协议数据会逐字成为捕获工具的 parameters。[统一 JSON 值 schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义词汇与校验语义,[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes)则定义组装、提交、守卫和终止停止算法。 ## 测试 -worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。 +worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。构建后二进制文件的冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。 ## 延迟(本轮明确的非目标) - **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。 - **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。 - **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(工具调用事件已经持久记录了脚本)。 -- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延迟的消息大声拒绝)。 +- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都会明确拒绝,并在消息中注明其已延迟实现)。 - **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 - **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。 - **面向人类界面的进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。 -- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。 +- **ACP(Agent Client Protocol)后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。 ## 曾考虑的替代方案 -- **宿主侧的恶意值防护**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆加结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。 -- **进程内 `node:vm` 执行**:机械上最简——无 RPC、无线程——但 `start()` 会在脚本的初始同步切片期间阻塞调用方,第一个 await 之后的同步自旋无法在进程内终止(vm `timeout` 仅覆盖第一个切片),且 `dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。 +- **宿主侧的恶意值防护**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆加结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上保证跨 realm 值的处理对所有输入都有确定结果。 +- **进程内 `node:vm` 执行**:机械上最简——无 RPC、无线程——但 `start()` 会在脚本的初始同步切片期间阻塞调用方,第一个 await 之后的同步自旋无法在进程内终止(vm `timeout` 仅覆盖第一个切片),且 `dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本接口,同时解除宿主阻塞并使终止成为现实。 - **后台执行作为默认**(CC 的形态):延迟。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash、subagent 和工作流之间统一设计一次,而非逐工具设计。 - **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`。 - **Meta 嵌入脚本中作为 `export const meta = {...}`**(CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter(当宿主读取结果对象时)。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。 - **`ValueSchemaSpec` 作为 `outputSchema` 协议类型**:面向作者的形式如今具有等价词汇,但工作流提供的是来自其他 realm 的原始 JSON Schema 数据;将这类运行时数据假装成可信的作者声明,会跳过原始 schema 断言边界。 - **schema 对象库(zod 或本仓库的 schemastery)用于结构化输出子集**:schema 是协议数据——纯 JSON,跨越 `agent({schema})` 中的 vm realm 边界并逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上加一个第三方转换器(zod core 只输出 JSON Schema,不能反向),且会在 schemastery 的配置角色旁边放置第二种 schema 语言。 -- **ajv 用于值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的第一个运行时依赖,仅为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误报告无论如何都是自定义的。 +- **ajv 用于值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的第一个运行时依赖,仅为替换约 70 行的值遍历器,而带路径且逐一报告所有违规的错误报告无论如何都是自定义的。 - **提供方 JSON 模式代替捕获工具**:它保证有效 JSON,不保证 schema 一致性,且它与工具调用的交互不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 后续可以在不改变本设计的情况下收窄接受的子集。 ## 后果 -扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 +扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 70dd43d0f6..6bb4aac193 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 36a2ae220db24c3183253c18957769ab5d5c068c -2026-07-05-skill-system.zh.md: 3a673d44d74ab38732dd5f5bda4d607b174db251 +2026-07-05-skill-system.md: dd2fb1d22949f55ea7cb2c9f280e7cfbfcbbb226 +2026-07-05-skill-system.zh.md: 96656a8e1dfc2ae1ce7301ba29e7739349b6aab6 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 36a2ae220d..dd2fb1d229 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -22,7 +22,7 @@ Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `na Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations. -`dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) +`dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/pre-step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills with `invocation.modelInvocable: false` retain distinct tool errors. The tool result is the model-visible disclosure path. diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 3a673d44d7..96656a8e1d 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 -`dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) +`dsh-tool-skill` 在会话的第一个 `agent/pre-step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) 注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index 48d53f1896..fa097ae230 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-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/feature/2026-07-06-approval-seam.md 2026-07-06-approval-seam.md: ae143a41302b7bcd6029345fa91ca4eda837c141 -2026-07-06-approval-seam.zh.md: a66ce804167fd4de5c8dbe51d70c9b038d1ebb17 +2026-07-06-approval-seam.zh.md: 2f95f67ecd33b4c659e56d5f3cc6168bf01e35ce diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index a66ce80416..2f95f67ecd 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP(Agent Client Protocol)桥、宿主适配器、测试脚本),而每会话的策略层可以在任何通道介入之前做出决定。消费方(`dsh-tools` 的 ask 路由和沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。 +一个包`dsh-user-approval`(`packages/ui/user-approval`)负责定义词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP(Agent Client Protocol)桥、宿主适配器、测试脚本),而每会话的策略层可以在任何通道介入之前做出决定。消费方(`dsh-tools` 的 ask 路由和沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。 ### 部署如何使用它 -一条 `cordis.yml` 条目挂载该 seam。不加载它就是失败关闭的退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。 +一条 `cordis.yml` 条目挂载该 seam。不加载它就是默认拒绝请求的退出方式:即使没有注册任何审批代码,消费方也会拒绝无法应答的请求。 ```yaml - id: approval @@ -25,9 +25,9 @@ Status: implemented # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。 +仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。 -组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。 +组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久化的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。 以下是该组合下的一次 ask,取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,自动化客户端选择 Allow once: @@ -51,9 +51,9 @@ tool/result "escalated" — this one call ran under the wider mode; the gra #### seam:机制与策略分离 -经过校验并成功追加 `approval/asked` 后,服务将 `approval/request` waterfall 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读的请求标识和 signal,将中止视为 `cancelled`,把应答者失败和无效返回容纳为 `unavailable`,丢弃迟到的应答,并追加配对的 `approval/decided` 事件。提交前的审计失败会拒绝;追加后的观察者失败无法撤销权威事件。`allowed-once` 仅授权所询问的操作,而 `request()` 会拒绝打开轮次之外的调用,以保证审计对留在持久提交边界内。 +经过校验并成功追加 `approval/asked` 后,服务将 `approval/request` waterfall 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务沿用只读的请求标识和 signal,将中止视为 `cancelled`,把应答者失败和无效返回统一转换为 `unavailable`,丢弃迟到的应答,并追加配对的 `approval/decided` 事件。提交前的审计失败会拒绝;追加后的观察者失败无法撤销权威事件。`allowed-once` 仅授权所询问的操作,而 `request()` 会拒绝打开轮次之外的调用,以保证审计对留在持久提交边界内。 -应答者是 `approval/request` waterfall 监听器。零监听器会一路委派至 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器随其 fiber dispose,因此卸载通道会失败关闭。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。 +应答者是 `approval/request` waterfall 监听器。零监听器会一路委派至 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器会随其 fiber 一同 dispose(资源释放),因此卸载通道后,请求会在故障时默认被拒绝。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。 `ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。 @@ -67,13 +67,13 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 ` #### ACP 应答者 -ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。 +ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。不属于该桥或没有调用标识的请求会继续委派;客户端 RPC 失败会转换为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。 应答者通过[仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 #### 审计,以及模型看到什么 -`approval/asked` 和 `approval/decided` 是持久的仅日志事件;模型只看到从结果派生出的普通工具结果。成功完成时,每个 `asked` 都提交一个 `decided`,包括取消和被容纳的应答者失败。空闲时的请求不追加任何事件;提交前失败会拒绝,而第二次追加失败可能留下一个已经提交但未匹配的 `asked`。 +`approval/asked` 和 `approval/decided` 是持久的仅日志事件;模型只看到从结果派生出的普通工具结果。成功完成时,每个 `asked` 都提交一个 `decided`,包括取消以及已转换为封闭结果的应答者失败。空闲时的请求不追加任何事件;提交前失败会拒绝,而第二次追加失败可能留下一个已经提交但未匹配的 `asked`。 #### 实体与依赖 @@ -81,7 +81,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 ### 测试 -单元测试固定结果、先到先得的委派、错误容纳、取消、作用域路由、审计配对、不可绕过的 `'never'` 策略、工具拒绝原因,以及通过真实脚本化桥实现的 ACP 归属/结果映射。 +单元测试锁定结果、先到先得的委派、错误转换、取消、作用域路由、审计配对、不可绕过的 `'never'` 策略、工具拒绝原因,以及通过真实脚本化桥实现的 ACP 归属/结果映射。 快照记录通过 `session/request_permission` 批准和拒绝沙箱升级,以及完整的 `'ask'` 与 `'never'` 运行时上下文贡献。没有脚本化应答的权限提示会取消并失败关闭。 @@ -93,9 +93,9 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 ## 曾考虑的替代方案 -- **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时失败关闭和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。 +- **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 直接复用运行时已有的组合能力、缺失时默认拒绝行为和 HMR(热模块替换)资源释放机制;seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。 - **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求策略硬编码进传输层,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。 -- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。二者骨架相似(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时失败关闭、以及审计事件。因此审批不走已交付的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果二者将来趋同,共享提供方管道仍然开放。 +- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。二者骨架相似(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时失败关闭、以及审计事件。因此审批不走已交付的 `packages/ui/user-interaction` / `ask_user_question` 信息征集路径——信息征集表单不是权限提示,自由文本应答不是封闭结果;如果二者将来趋同,共享提供方管道仍然开放。 - **`dsh-tools` 中的静态可选注入**:否决。vendor 的 Cordis `Inject` 类型没有 optional 标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,跨 HMR 正确降级,无需额外机制。 - **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。此处服务体是固定机制,可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。 - **现在就提供 `allow_always`**:否决。协议能表达它,但兑现它意味着设计授权存储、作用域标识和撤销(§ 延后)。展示 harness 无法兑现的选项只会制造注定失败的授权。 @@ -111,13 +111,13 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 代价与已接受的局限: -- **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 -- **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。 +- **两个都会直接作出决策的应答者会竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 +- **生产路径仅在一种组合下得到验证。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。 - **归属以 `Agent` 对象标识为键。** 应答者先在 `agent.session.id` 处解析会话映射记录,再要求该记录拥有精确的 agent 对象;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭,届时需要另一种归属契约。 ## FAQ -- **在完全没有应答者的部署中(headless、CI)会发生什么?** 每次 ask 穿过空的 waterfall 降级为 `unavailable`,工具调用以「no approval channel is available」原因被拒绝。失败关闭是零监听器的默认行为,不是配置。 +- **在完全没有应答者的部署中(headless、CI)会发生什么?** 每次 ask 都会沿空的 waterfall 落到 `unavailable`,工具调用以「no approval channel is available」原因被拒绝。失败关闭是零监听器的默认行为,不是配置。 - **授权能持久化吗——「始终允许」?** 不能。`allowed-once` 仅授权单次被询问的操作,服务在请求之间不存储任何内容;`allow_always` 在授权存储设计完成之前刻意不展示(§ 延后)。 - **模型看到审批的什么?** 只看到发起方从结果派生的工具结果——审计对永远不进入 transcript(文本记录)。三种非授权原因各不相同,模型可以区分人类说「不」、提示被关闭、通道缺失。 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml index 1c1ae2daf2..c01e419b7d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md 2026-07-06-explicit-tool-order.md: bd6d0a04aa470ca33e618957ae1f08c1ef15fcfe -2026-07-06-explicit-tool-order.zh.md: 5cdecc0e59ff00b6dce7134819f8230072d084cb +2026-07-06-explicit-tool-order.zh.md: 88955aaaa55b18ac94153b34d4e514ecc1fa1100 diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md index 5cdecc0e59..88955aaaa5 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的 header,因此需要一个显式的确定性策略。 +模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的请求头,因此需要一个显式的确定性策略。 ## 决策 -系统提示词的组装逻辑拥有模型侧工具顺序的权威定义,正如它已经拥有 section 顺序的权威定义一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略: +系统提示词组装逻辑负责权威定义模型侧工具顺序,正如它已经负责权威定义 section 顺序一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略: - 列表中已注册的工具按列表位置排列。 - 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称则在每次 `assemble()` 时拒绝——这是已注册工具集存在并可供检查的最早时刻(工具插件在服务构造之后才注册),也是唯一的通用时刻(注册随时可能变化;Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop(智能体循环)下,第一个轮次在发出任何模型请求之前就会失败——确切的影响范围见下文「后果」。 @@ -19,7 +19,7 @@ Status: implemented - 列表必须恰好包含一个 rest 条目,且不得有重复名称。 - 当 `toolOrder` 未设置时,权威顺序为纯字典序(code-unit 比较,与 locale 无关),因此无需配置即可保证确定性。 -`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前对提供方工具进行规范化排序,从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。 +`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前将提供方工具归一为权威顺序,从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。 范围刻意收窄:本 Agent Note 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。 @@ -40,12 +40,12 @@ Status: implemented - 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转从结构上被消除,默认为字典序。 - 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序开始;提供方注册顺序在该协作 seam 之前无处可观测。 -- 快照套件中唯一固定请求头的 fixture(`text-turn`)携带新的权威工具顺序;按照固定请求头设计,其他 ACP 快照仍将大块 header 清洗为 `{{system}}`/`{{tools}}`。 +- 快照套件中唯一锁定请求头的 fixture(`text-turn`)携带新的权威工具顺序;按照锁定请求头的设计,其他 ACP 快照仍将大段 header 替换为 `{{system}}`/`{{tools}}`。 - 步骤之间的纯工具重排与其他 header 变更一样记录:一份原因是 `'change'` 的完整 `request/header` 快照。稳定的权威顺序会防止注册时序在普通路径上制造这类变化。 - `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。 -- `toolOrder` 中拼错或未加载的工具名称在提示词组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 +- `toolOrder` 中拼错或未加载的工具名称在提示词组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因完整结束并携带错误消息,`agent/error` 复现该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 - 工具提供方返回保留的 rest 条目名称时,其提示词组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。 ## 测试 -系统提示词测试覆盖:字典序默认顺序、列表/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。Loop 测试固定:跨注册排列的已记录与已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结的请求,以及在配置了未知名称时的平衡轮次失败(无步骤、无 header、无适配器调用)。快照回放仅在固定的 `text-turn` header 中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。 +系统提示词测试覆盖:字典序默认顺序、列表/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。Loop 测试锁定:在不同注册顺序下,已记录与已分发的顺序一致、配置通过 agent-core 和两个 app 转发、请求经过深度冻结,以及在配置了未知名称时的平衡轮次失败(无步骤、无 header、无适配器调用)。快照回放仅在固定的 `text-turn` header 中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index d5727d9c4b..7f15a55333 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 4355ab57374f77f1733fff39e2fb4ccadcedaf6b -2026-07-06-sandbox.zh.md: 02c5337555b7466c2bac7fc4774cdd2c945178ae +2026-07-06-sandbox.md: aed5ac1ceb02130ce97a8c83c0f77869fdc32146 +2026-07-06-sandbox.zh.md: db95b1a5b7a7cae1e0fcdd8deba9dcb6ad020a67 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 4355ab5737..aed5ac1ceb 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -36,11 +36,11 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs ``` -The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before, spawned through the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. +The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. -Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. +Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner rejects with attributable `ENOENT` or `EACCES`, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. -Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The owner-derived runtime context states the current file policy without replacing those enforcement boundaries. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly. +Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The owner-derived pending policy context states the current file policy without replacing those enforcement boundaries. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly. ### Design detail @@ -50,7 +50,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its structured runner-failure evidence (`runnerFailureRules`, optional allowed exit codes plus fatal per-line signatures after exact informational-line exclusions); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -60,25 +60,25 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable fails through the attributable `ENOENT`/`EACCES` spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured script necessarily owns its interpreter startup before it applies its profile. -The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. +The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. A runner failure outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE`; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection is runner-owned only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]; a bare `syscall: 'spawn'` without an exact error path, other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution converts runner-owned rejections to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessService` that synchronously throws the same provenanced shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. -The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. +The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. #### Escalation: one approved wider retry after a denial `BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. -`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. +`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` receives spawn failure out of band from stderr classification and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. @@ -105,7 +105,7 @@ interface SessionEventMap { Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern. -Sandbox and approval policy are rendered as ordered contributions to one runtime-context snapshot before every request. The loop records the complete snapshot as a sourced `user/message`; both `'ask'` and `'never'` are explicit, so neither owner needs switch narration or last-told state. +Before each proposed step, sandbox and approval policy are rendered as ordered contributions to one desired policy-context message. The listener reconciles that message against session history, the claimed batch, and its pending `next-step` inbox entry. Once claimed and entered, the loop records the complete sourced `user/message`; both `'ask'` and `'never'` are explicit, so neither owner needs switch narration or last-told state. **The optional UI surface** is `PermissionService`: a deployment-defined preset table whose entries bundle one sandbox mode with one approval policy. The shipped `workspace-write` and `danger-full-access` presets write through to both domain setters; a knob combination outside the table is reported as `custom`. UI adapters may expose that table as a selector. The automation-only ACP transport advertises no configuration selector and mounts no permission-preset service. @@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, malformed-runner negative controls, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. -- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent. +- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. ## Deferred phases @@ -128,12 +128,12 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. -- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus `runnerFailureSignatures` classification carries the safety property instead. +- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. - **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. - **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). @@ -149,9 +149,9 @@ Each phase gets its full design when picked up, validated against the code at th - **Per-session dynamic tool schemas** — rejected: schemas are registry-global by design (one assembly vocabulary, the pinned-header snapshot contract), and re-registering per session would buy only what the execution-time strict-wider check already guarantees, at the cost of a per-session schema surface and header churn on every switch. - **Hard-match the retry to a prior denial** — rejected: command-string identity is fragile (quoting, `workdir`, env prefixes, a pipeline retried as its failing stage) — false-rejects honest retries or is trivially satisfied; the real boundary is the human seeing command + justification. Revisit only if `allow_always` grant storage ever needs machine-checkable scopes. - **A generic `env/state` facts map with an owner service** — rejected: approval and sandbox compose independently, so neither's state may drag in a third package; single-key folds are one `findLast` each, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing. -- **Narrate each switch through `agent.inject()` plus a bus event** — rejected: independent notices expose owner ordering and intermediate combinations, while one assembly pass can materialize the complete current state atomically at the request boundary. +- **Narrate each switch through `agent.inject()` plus a bus event** — rejected: independent notices expose owner ordering and intermediate combinations, while one pre-step composition can enqueue the complete current state atomically. - **State sandbox mode in the stable system prompt** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery. The absence decision is superseded by [the current-policy decision](2026-07-30-current-sandbox-policy-context.md); this measurement and causal observation remain the evidence that any replacement must counter-test. -- **Track "last told" with its own bookkeeping events** — rejected: the latest sourced runtime-context `user/message` records the exact full snapshot the model saw. Materializing that snapshot from current owner contributions replaces a second bookkeeping stream — events are needed only where they ARE the store. +- **Track "last told" with its own bookkeeping events** — rejected: session history records the exact policy context the model saw, while the claimed batch and pending inbox entry show what is entering or queued. Recomputing the desired message replaces a second bookkeeping stream — events are needed only where they ARE the store. - **Independent sandbox and approval selectors** — rejected: one deployment-defined permission preset keeps the two policy knobs coherent for UI clients that expose runtime switching. ## Consequences @@ -160,38 +160,39 @@ What shipped pins — the tiers in Testing hold each: - A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing. - The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched. -- One sourced runtime-context message states the complete current sandbox and approval policies atomically; the whole exchange — context snapshots, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events. -- One preset selection records only changed knob values, while a no-op selection records nothing; the next request snapshots both current values atomically, and a committed sandbox switch is honored by the next call's stamp. -- A resumed session's overrides enter its first new runtime-context snapshot with no catch-up state; a composition default changed while the process was down likewise appears in that snapshot. +- One sourced policy-context message states the complete current sandbox and approval policies atomically; the whole exchange — context messages, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events. +- One preset selection records only changed knob values, while a no-op selection records nothing; the next pre-step upserts both current values atomically, and a committed sandbox switch is honored by the next call's stamp. +- A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message. - Two concurrent sessions never see each other's state or notices. - Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. -- Policy ownership stays in plugins through `systemPrompt.context`, `SessionEventMap` merging, and capability-owned resolution; the generic loop change materializes every owner's ordered context as one sourced message. +- Policy ownership stays in plugins through `SessionEventMap` merging, inbox mutation from `agent/pre-step`, and capability-owned resolution; the generic loop only claims and records the final entered batch. Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. -- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown `SANDBOX_UNAVAILABLE`, the command never runs; fail closed, never open. +- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. +- **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. -- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. +- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. - **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message. - **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message. ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. +- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessService` that synchronously throws the same provenanced `ENOENT`/`EACCES` shape makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry. -- **When does a runtime mode switch take effect?** Once its session event commits, the next runtime-context snapshot and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use. -- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full runtime-context snapshot. +- **When does a runtime mode switch take effect?** Once its session event commits, the next pre-step policy-context reconciliation and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use. +- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full policy-context message. - **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`. ## Prior art diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 02c5337555..db95b1a5b7 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -14,7 +14,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 ## 决策 -一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层杠杆:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 +一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 ### 部署方式 @@ -36,11 +36,11 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs ``` -这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,通过提供方返回的包装 argv spawn。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 +这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 argv 启动。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 -配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段(命令 spawn 之前)抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 +配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 -被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的运行时上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 +被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 ### 设计细节 @@ -50,7 +50,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### seam:`ctx.sandbox` -`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串)、以及其 runner 失败方言(`runnerFailureSignatures`,runner 本身失败——因而命令从未运行——时的自我标识方式);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root)以及 `SandboxPolicy`(提供给约束后端的子集)。 +`dsh-sandbox` 负责定义词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串),以及其结构化 runner 失败证据(`runnerFailureRules`,可选的允许退出码加上排除整行精确信息性行后按行匹配的致命签名);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + 工作区根目录)以及 `SandboxPolicy`(提供给约束后端的子集)。 策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 @@ -60,25 +60,25 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败签名,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。`runnerCommand` 作为运维人员对 bwrap 形状 runner 的断言跳过选择,但缺失或不可执行的命令仍被归类为沙箱失败,绝不无约束地运行负载。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本会通过可归因的 `ENOENT`/`EACCES` spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器,再应用自身 profile。 -launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec。 +launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测和 CLI flag,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。Runner 失败优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,拒绝才会归因于 runner;没有精确错误路径的裸 `syscall: 'spawn'`、其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将可归因于 runner 的拒绝转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 -模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 +模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 #### 升级机制:拒绝后一次经批准的更宽重试 `BashExecRequest.sandboxPolicy` 是可选的完整按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 仍是公布已挂载执行器能否兑现该策略的能力事实,因此只有约束组合才暴露升级。seam 接受任何显式策略;工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 -`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `BashProcess` 为键;`onProcessDone()` 在 `done` 结算前分类 stderr 并给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。 +`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `BashProcess` 为键;`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败,并在 `done` 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。 当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 @@ -92,7 +92,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启后仍然有效以及多会话隔离均由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): @@ -103,9 +103,9 @@ interface SessionEventMap { } ``` -每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表:第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 +每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无需共享的归属服务、通用 facts map 或注册表:第三个配置项只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 -沙箱策略与批准策略会在每次请求前渲染为同一份运行时上下文快照中的有序贡献。循环会将完整快照记录为一条带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。 +每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。 **可选的 UI 界面**是 `PermissionService`:一张部署定义的 preset 表,每个条目捆绑一个沙箱模式与一个批准策略。随附的 `workspace-write` 和 `danger-full-access` preset 写入两个领域 setter;preset 表之外的旋钮组合报告为 `custom`。UI 适配器可以把该表暴露为选择器。仅面向自动化的 ACP 传输层不公布任何配置选择器,也不挂载权限 preset 服务。 @@ -117,23 +117,23 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及运行时上下文排序与具体化。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 +- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 -- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 +- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 ## 延迟阶段 -每个阶段在被拾起时获得完整设计,对照当时的代码验证,并在其涉及的层级带上单元测试、真实 API e2e 和快照覆盖率落地。 +每个阶段在开始实施时都会完成完整设计,并对照当时的代码验证,同时在其涉及的层级配套单元测试、真实 API e2e 和快照覆盖。 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 -- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加 `runnerFailureSignatures` 分类承载了安全属性。 +- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 - **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 - **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 @@ -149,9 +149,9 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **按会话动态工具 schema**:否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照契约),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 表面和每次切换的 header 变动。 - **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。 - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 -- **通过 `agent.inject()` 加总线事件逐次叙述切换**:否决。独立通知会暴露归属方顺序和中间组合,而一次组装过程可以在请求边界以原子方式具体化完整的当前状态。 +- **通过 `agent.inject()` 加总线事件逐次叙述切换**:否决。独立通知会暴露归属方顺序和中间组合,而一次 pre-step 组合可以原子排队完整的当前状态。 - **在稳定系统提示词中声明沙箱模式**:先行交付,随后根据线上证据移除:每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。 -- **用专门的簿记事件追踪「上次告知」**:否决。最新一条带来源的运行时上下文 `user/message` 会记录模型看到的确切完整快照。根据当前归属方贡献具体化该快照,取代了第二条簿记流——事件仅在它们本身即为存储时才需要。 +- **用专门的簿记事件追踪「上次告知」**:否决。会话历史记录模型看到的确切策略上下文,已领取批次与待处理 inbox 条目则表明正在进入或已经排队的内容。重新计算目标消息取代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。 ## 后果 @@ -160,38 +160,39 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。 - 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。 -- 一条带来源的运行时上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文快照、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。 -- 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一个请求会把两个当前值共同纳入一份原子快照,已提交的沙箱切换由下一次调用的盖章兑现。 -- 恢复会话的覆盖项会进入其首个新运行时上下文快照,无需追赶状态;进程停止期间变更的组合默认值也会出现在该快照中。 +- 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。 +- 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一次 pre-step 会原子 upsert 两个当前值,已提交的沙箱切换由下一次调用的盖章兑现。 +- 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。 - 两个并发会话永远看不到彼此的状态或通知。 - 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 -- 策略归属仍通过 `systemPrompt.context`、`SessionEventMap` 合并和由能力归属方拥有的解析留在插件中;通用循环变更会将每个归属方的有序上下文具体化为一条带来源的消息。 +- 策略归属仍通过 `SessionEventMap` 合并、从 `agent/pre-step` 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。 代价与已接受的限制: - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 - **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 -- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 +- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 -- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 +- **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 +- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试环节会验证安装产物的实际行为。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 -- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 +- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 - **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。 - **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。 ## FAQ -- **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** Runner 失败在分类中优先于拒绝:匹配包装的 `runnerFailureSignatures` 的失败运行意味着命令从未运行——前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带 runner 的 stderr 行,后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。损坏的沙箱永远不会被读作失败的命令,且命令永远不会无约束运行。 +- **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 +- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 - **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。 -- **运行时模式切换何时生效?** 一旦其会话事件提交,下一个运行时上下文快照与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。 -- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一份完整运行时上下文快照。 +- **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。 +- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。 - **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。 ## 先例 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 7e9f4d52e6..8ac4cd01ef 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -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/feature/2026-07-07-mcp-client-plugin.md 2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661 -2026-07-07-mcp-client-plugin.zh.md: d2ff04d68033402fbb2718f8624e44d35d860db3 +2026-07-07-mcp-client-plugin.zh.md: 88401ad63bce0b690561acf3bb820b3b758779fa diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index d2ff04d680..88401ad63b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -14,7 +14,7 @@ harness 此前无法消费 MCP(Model Context Protocol)生态中的工具。M ### 包 -单个包(package) `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」([能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md))。 +单个包 `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」([能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md))。 ### SDK @@ -54,7 +54,7 @@ interface StreamableHttpConfig { type Config = StdioConfig | StreamableHttpConfig ``` -`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而非远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。 +`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而非远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的配置手段。 `cordis.yml` 用法示例: @@ -94,7 +94,7 @@ type Config = StdioConfig | StreamableHttpConfig mcp____ -这种按服务器限定的形式是多服务器 agent 客户端的事实标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 +这种按服务器限定的形式是多服务器 agent 客户端事实上的标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 @@ -142,7 +142,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 2. 映射结果: - - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。 + - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(之所以必须这样做,是因为 `flattenText` 使用无分隔符的 `join('')`,多个内容块会丢失块间边界)。 - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[删除图片 Agent Note](../simplification/2026-07-04-drop-image-content-block.md))。 - `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。 3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。 @@ -197,9 +197,9 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha ## 测试 -覆盖率按层级命名;每个行为放在能表达它的最低成本层级。 +覆盖范围按层级列出;每项行为都放在能够表达它的最低成本层级。 -- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、代切换/回滚、重新同步失败时的保留、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 +- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 - **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。 - **快照**:刻意不做。MCP 工具不引入新的展示形态——它们以原始 `ToolDefinition` 注册,UI 消费方使用各自展示测试套件已固定的通用卡片兜底。将 MCP 服务器添加到某个可运行的快照组合会改变其已固定的系统提示词 fixture,且使每次回放依赖于 spawn 外部 MCP 服务器进程,而新增行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。 @@ -210,5 +210,5 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - `mcp____` 限定符在每个名称上消耗 token。已接受:描述和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配模式(`mcp__*`、`mcp__github__*`)。 - **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。 -- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 有有界的完全停稳过程;卡住的传输层最终在框架层面超时。 +- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。 - 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 56c5d930b4..487726c0e3 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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/feature/2026-07-08-self-referential-cordis-toolset.md 2026-07-08-self-referential-cordis-toolset.md: 335d5e808016ebc37c8457c5dcf4d7da9d8b8c93 -2026-07-08-self-referential-cordis-toolset.zh.md: 8d2a105a2189aa23915f718c6440069d1a2aa9ed +2026-07-08-self-referential-cordis-toolset.zh.md: 46d492cd92495a1e4d516db3f82c9e75486bc386 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 8d2a105a21..46d492cd92 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 `examples/web-cordis` 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 `examples/web-cordis` 演示。它为模型提供三个工具,用于操作当前 DSH 进程中的活跃 Cordis 运行时:检查该运行时、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 @@ -60,7 +60,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ## 曾考虑的替代方案 -**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 +**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及配套工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 | 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | |---|---|---| diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index f756be1482..ece6f1a094 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md 2026-07-10-agent-session-identity-and-log-location.md: 7b51ae41ac00c12a496940c891092580003646fa -2026-07-10-agent-session-identity-and-log-location.zh.md: 2574e1327f424069cdff68ef9b8de20c490077f0 +2026-07-10-agent-session-identity-and-log-location.zh.md: 46c7c1ce429d66741cffbdd87eb415b90cf32fb3 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 2574e1327f..46c7c1ce42 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 向工具与钩子公开 agent(智能体)会话标识和 JSONL 位置 +# Agent Note: 向工具与钩子公开 agent 会话标识和 JSONL 位置 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent 可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 +agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。 @@ -27,9 +27,9 @@ interface SessionPersistence { } ``` -`path` 是指向该后端为 `meta` 保留的专用日志的绝对本地路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告延迟创建的目标路径。 +`path` 是该后端为 `meta` 保留的专用日志的本地绝对路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。 -面向模型的 bash 包(package)拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。 +面向模型的 bash 包拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。 注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层: @@ -62,7 +62,7 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器契约测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。 -一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照覆盖会锁定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 +一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 28ca5e0ae4..3119d28d71 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -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/feature/2026-07-10-parallel-tool-call-execution.md 2026-07-10-parallel-tool-call-execution.md: 19f5dc189821433052edfa72613980a2e94e2cae -2026-07-10-parallel-tool-call-execution.zh.md: e90e357180ae3684500adf7cba41c5fc1dac5743 +2026-07-10-parallel-tool-call-execution.zh.md: 69bff90c11fa132ded325ef610dabf5c609f21af diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index e90e357180..69bff90c11 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -20,7 +20,7 @@ Status: implemented 这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。 -`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将以安全方式退化为独占。 +`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将按安全侧原则归为独占。 使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器契约。 @@ -42,7 +42,7 @@ Status: implemented 每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。 -只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行,因此必须能在不同执行之间重入。 +只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会包裹并发派发过程,因此必须能在不同执行之间重入。 每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 @@ -66,9 +66,9 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c ## 验证 -单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定每项并行声明。 +单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 -快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 +快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 ## 备选方案 @@ -78,7 +78,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c **使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。 -**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于关系时安全退化。 +**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于调用间关系时按安全侧原则处理。 **并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。 @@ -94,7 +94,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c ## 影响 -该设计以安全退化为原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。 +该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。 在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index 6d9595a8e9..8f5de6df71 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md 2026-07-10-sqlite-session-query-provider.md: 372c21241f9ae5d7300165f016db9b36e6b52855 -2026-07-10-sqlite-session-query-provider.zh.md: dc10a6e6a609809aa6f2b194f262e2642d9545bc +2026-07-10-sqlite-session-query-provider.zh.md: b0789f18f5f334b14fe603ee36924666705136f1 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md index dc10a6e6a6..b0789f18f5 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤器、分页、取消以及重建行为。 +精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个包含上次持久化检查点之后更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤器、分页、取消以及重建行为。 如果把这些关注点拆分到提供方协调器和数据库实现之间,就会产生两个耦合的对齐状态机。第一个实现既要暴露精简的提供方无关调用契约,也要在同一个生命周期内管理源观察、提取、SQLite 事务、代际与查询执行。 @@ -34,13 +34,13 @@ Status: implemented 共享提取器会提取消息文本、推理(reasoning)、嵌套的工具调用/结果内容、工具名称与参数、被阻止提示词的原因、待办事项状态与内容,以及错误或结束状态详情。结构性边界、流式分片、请求头、成功完成标记,以及通过声明合并扩展的未知事件/内容变体都不会产生文档。surface 分类复用 `foldSurface()`,使搜索与模型历史派生保持一致。 -一个串行化操作会读取提供方无关的 `SessionPersistence` 快照清单,将每个包含源身份的不透明修订号与同已索引会话一并存储的修订号比较,只加载新增或变更的日志,在一个事务中对齐各行,然后执行查询。它绝不会调用后端会修改状态的 `load()` 来处理当前由 `ctx.sessions` 拥有的 id;TEMP 覆盖层会记录持久化可用性,实时所有者分离后,持久化基础层随之刷新。修订号同时标识其底层持久化存储与后端本地日志修订版本,因此针对同一存储重新打开服务可以复用已索引行,而切换到独立存储时不会因会话 id 与本地计数器相同而发生冲突。如果加载期间清单发生变化,系统会重复观察;因此,会修改状态的加载修复所产生的新修订号会在提交前纳入结果。重复查询与针对未变更存储的重新打开都不会加载完整的持久化日志。新增、变更与删除的会话会在下一次稳定搜索中更新。源或提取失败不能将某一行标记为当前状态,事务失败则会回滚,使后续搜索能够重试。 +一个串行化操作会读取提供方无关的 `SessionPersistence` 快照清单,将每个包含源身份的不透明修订号与同已索引会话一并存储的修订号比较,只加载新增或变更的日志,在一个事务中对齐各行,然后执行查询。它将调用方传入的原始中止信号传给快照清单查询和非变更式检查,直接等待每个已启动的后端操作,并在每次等待结束后、启动更多工作之前检查取消状态。因此,即使后端忽略该信号,取消也只会在活跃的后端工作完全停稳后才拒绝,不会启动后续观察或对齐步骤;后续搜索仍会串行等待清理完成。它绝不会调用后端会修改状态的 `load()` 来处理当前由 `ctx.sessions` 拥有的 id;TEMP 覆盖层会记录持久化可用性,实时所有者分离后,持久化基础层随之刷新。修订号同时标识其底层持久化存储与后端本地日志修订版本,因此针对同一存储重新打开服务可以复用已索引行,而切换到独立存储时不会因会话 id 与本地计数器相同而发生冲突。如果加载期间清单发生变化,系统会重复观察;因此,会修改状态的加载修复所产生的新修订号会在提交前纳入结果。重复查询与针对未变更存储的重新打开都不会加载完整的持久化日志。新增、变更与删除的会话会在下一次稳定搜索中更新。源读取或提取失败时,不能将相应行标记为最新状态,事务失败则会回滚,使后续搜索能够重试。 持久化文档在重启后仍然存在。实时会话使用连接本地的 TEMP 表,遮蔽相同 id 的持久化基础行,并在实时所有者分离时重新显露该基础行。关闭数据库会删除实时行。卸载持久化服务会隐藏持久化行,但不会把缺失视为权威删除;重新挂载后,系统会再次观察并对齐后端。实时会话头与持久化会话头的不可变字段发生冲突时,系统会失败,而不会合并两个来源。 派生 schema 拥有独立的 application id 与单调递增的 schema 版本。持久化与 TEMP 会话元数据均遵循 `SessionHeader.createdAt` 的整数契约,将其存入严格的 `INTEGER` 列。系统识别到不兼容版本时,只会重置该派生数据库。如果数据库具有不属于本应用的 application id 或无法识别的用户表,系统会在修改日志模式前拒绝该数据库,防止意外配置的规范会话数据库遭到修改。在 POSIX 文件系统上,缺失的目录与数据库文件会以仅所有者可访问的权限创建,使新的 SQLite 伴随文件沿用该模式;现有权限模式保持不变。一个进程中的一个服务独占一条派生索引路径;代际与实时 TEMP 遮蔽状态都归连接所有,因此不支持跨进程写入方。 -取消会拒绝排队中的操作,并终止调用方对异步源观察的等待;已经中止的观察结果不会提交。Node 的同步 `DatabaseSync` MATCH 调用一旦开始在 JavaScript 线程上执行就无法中断,因此服务会在串行化边界检查信号,但不承诺在语句执行期间抢占。 +取消会使排队中的操作及时被拒绝。异步源观察一旦开始,调用方必须等待该后端 Promise 结算后才会收到拒绝;系统不会提交已中止的观察结果,也不会启动更多源观察或索引工作。Node 的同步 `DatabaseSync` 元数据与 MATCH 调用一旦开始在 JavaScript 线程上执行就无法中断,因此服务会在这些调用前后检查信号,但不承诺在语句执行期间抢占。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index d7dfbedded..205ce40bb2 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md 2026-07-12-subagent-persona-tool-filter-and-depth.md: c690f4701a54272205eedf719fbfe0863bac566c -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6e9e9ad44fff4dee6ddb286227485420d100f4ee +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: e14214b16c413eaa05bcea7bcdf9bca996e8d616 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 6e9e9ad44f..e14214b16c 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -32,18 +32,18 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。 -### 工具过滤是一条作用于全局视图的活规则 +### 工具过滤是一条作用于实时全局视图的规则 工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。 解析遵循以下规则: 1. 每条限制对活跃的部署全局工具注册表先应用 `allow` 再应用 `deny`。 -2. 多条限制取交集,因此每条已安装的限制都必须放行一个全局工具。 +2. 多条限制取交集,因此一个全局工具必须得到每条已安装限制的放行。 3. 子 agent 作用域的工具在全局过滤之后添加,可以遮蔽一个已放行的全局工具。 4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不受全局过滤器影响。 -当过滤器既未提供 `allow` 也未提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留名称)时,配置会显式失败。`allow: []` 合法,且有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来仍然有效。 +当过滤器既未提供 `allow` 也未提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅存在于局部作用域的名称或保留名称)时,配置会显式失败。`allow: []` 合法,且有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来仍然有效。 全局注册表保持活跃。仅 deny 的过滤器会放行后来注册的全局名称(除非显式 deny 该名称);allow 列表会排除后来注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有已解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。 @@ -53,7 +53,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 Loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[SDK 辅助函数生成的 subagent 条目](../../../../packages/sdk/helper/src/features/builtin/index.ts)和 [JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而已交付的交互式 ACP、headless 和 REPL 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[SDK 辅助函数生成的 subagent 条目](../../../../packages/sdk/helper/src/features/builtin/index.ts)和 [JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而已交付的交互式 ACP、headless 和 REPL 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。 @@ -73,9 +73,9 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 仅持有父级子集授权,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 -具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升权保证。 +具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的动态组合语义,并不构成非升权保证。 -安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本功能范围内。 +安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力、输出、终止标签均不在本功能范围内。 ## 曾考虑的替代方案 @@ -93,4 +93,4 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 -代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升权问题。 +代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的非升权问题。 diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml index ca8877d5a7..80a1952578 100644 --- a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-13-session-query-tracing.md 2026-07-13-session-query-tracing.md: 47c12824a331546676d3bc79920f861afe648431 -2026-07-13-session-query-tracing.zh.md: 485060f9e57b5644f7b364e2120bfe30607b1945 +2026-07-13-session-query-tracing.zh.md: 88ff93048db0e763888ce6f7fee60918be3675a7 diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md index 485060f9e5..88ff93048d 100644 --- a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md @@ -27,9 +27,9 @@ Status: implemented - **公开独立的追踪辅助函数**:不予采纳,因为源优先级与状态分离边界属于 `ctx.sessionQuery`;公开辅助函数会诱使调用方绕过该边界。 - **合并替换边与来源边**:不予采纳,因为位置替换可以遮蔽表面节点,同时引用不在表面上的构造输入,而消费方需要区分这两种含义。 - **返回传递来源闭包**:不予采纳,因为这会掩盖日志中直接记录的证据、增大结果,并让一条遥远的格式错误边改变原本局部的输出。 -- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查必须快速失败。 +- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查会明确报错。 -## 影响 +## 后果 消费方无需缓存或引入第二份语料,即可获得确定性的关系视图。事件追踪每次调用都会执行全日志校验和分配,而谱系追踪每次调用都会列出完整的逻辑语料。这些成本让真源保持明确,并且与承载内容的全文搜索及过滤 API 相互独立。 diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 74ad64601b..9697abde6f 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md 2026-07-14-cross-family-fs-sandbox.md: e8a59be345b52f7684c574134b37f48bc49843fc -2026-07-14-cross-family-fs-sandbox.zh.md: 92bc5a495a7c20a08bc85ef9dbf1a1beffe6264f +2026-07-14-cross-family-fs-sandbox.zh.md: f816bc88db3d7624892fb057e63dd5f24381275e diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index 92bc5a495a..f816bc88db 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -6,30 +6,30 @@ Status: implemented ## 问题 -`SandboxMode` 声明的是文件效果,但最初只有 `ctx.bash` 执行它。fs 工具(`write`/`edit`)在进程内经由 `ctx.fs` 变更宿主文件系统,那里的 OS argv 包装在机制上毫无意义——[沙箱 RFC](2026-07-06-sandbox.md) § In-process tools 记录了这一点,并把跨家族执行留作一个延后阶段,附带一个未决问题:进程内执行是各 seam 各自表达,还是变成一个统一的 harness 能力。本 Agent Note 就是那个阶段,并给出答案:一个共享的策略归属,在每个家族各自正确的高度上做 per-seam 执行。 +`SandboxMode` 所声明的语义涵盖文件系统效果,但最初只有 `ctx.bash` 强制执行该策略。fs 工具(`write`/`edit`)在进程内经由 `ctx.fs` 变更宿主文件系统,那里的 OS argv 包装在机制上毫无意义——[沙箱 Agent Note](2026-07-06-sandbox.md) § In-process tools 记录了这一点,并把跨家族强制执行留作一个暂缓阶段,附带一个未决问题:进程内强制执行是各 seam 各自表达,还是变成一个统一的 harness 能力。本 Agent Note 就是那个阶段,并给出答案:一个共享的策略归属,在每个家族各自正确的层级上做 per-seam 强制执行。 -这个缺口不是 read-only 形状的。一个受限编码 agent 的产品模式是 `workspace-write`:bash 已经可以在工作区根目录下写入,而其外的一切都被拒绝,所以一个只能全部拒绝的 fs 执行会严格劣于禁用 fs 工具——模型会尝试在工作区内 `write`,被拒,然后学会绕道 `bash` heredoc。因此跨家族执行必须讲完整的模式阶梯,包括 `workspace-write` 要求的路径包含判定(规范化目标;`..`/符号链接/绝对路径逃逸),以及与 bash 相同的升级杠杆。 +这个缺口并不只有 read-only 一种形态。一个受限编码 agent(智能体)的产品模式是 `workspace-write`:bash 已经可以在工作区根目录下写入,而其外的一切都被拒绝,所以只能全部拒绝的 fs 强制执行会严格劣于禁用 fs 工具——模型会尝试在工作区内 `write`,被拒,然后学会绕道 `bash` heredoc。因此跨家族强制执行必须涵盖完整的模式阶梯,包括 `workspace-write` 要求的路径包含判定(规范化目标;`..`/符号链接/绝对路径逃逸),以及与 bash 相同的升级手段。 -第二个执行家族还暴露了原布局中的一个归属问题。部署默认值(`mode` + `workspaceRoot`)配置在 `dsh-bash-sandbox` 上,而 per-session 覆盖事件是 `bash/sandbox-mode`,由 `dsh-bash` 的 session-mode 工具集折叠与写入。当 fs 执行同一套策略时,要么 fs 读取 bash 的配置与事件(一个能力家族依赖同级插件的配置),要么各家族各持一份副本——两份 `workspaceRoot` 会漂移进沙箱 RFC 警告过的那个割裂世界:bash 受限于一个根,而 fs 围栏另一个根。 +第二个强制执行家族还暴露了原布局中的一个归属问题。部署默认值(`mode` + `workspaceRoot`)配置在 `dsh-bash-sandbox` 上,而 per-session 覆盖事件是 `bash/sandbox-mode`,由 `dsh-bash` 的 session-mode 工具集折叠与写入。当 fs 强制执行同一套策略时,要么 fs 读取 bash 的配置与事件(一个能力家族依赖同级插件的配置),要么各家族各持一份副本——两份 `workspaceRoot` 会漂移进沙箱 RFC 警告过的割裂世界:bash 受限于一个根,而 fs 围住另一个根。 -## Decision +## 决策 -三个相互协调的部分,全部在叶子 `cordis.yml` 中组合,均不触及 `agent-loop`。 +三个相互协调的部分全部在叶子 `cordis.yml` 中组合,均不触及 `agent-loop`。 ### `ctx.sandboxPolicy`——mode 与工作区根的统一归属 -`packages/sandbox/sandbox-policy/`(`@deepseek-ai/dsh-sandbox-policy`)注册 `ctx.sandboxPolicy`,即部署沙箱策略的唯一所有者: +`packages/sandbox/sandbox-policy/`(`@deepseek-ai/dsh-sandbox-policy`)注册 `ctx.sandboxPolicy`,即部署沙箱策略的唯一所有者: -- `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误在加载时高声失败。 -- per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它住在这里,而不在任一能力的 seam 里。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 +- `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误会在加载时明确报错。 +- per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它归于此处,而不归于任一能力的 seam。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 - `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 - 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。 -`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 +`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP(Agent Client Protocol)bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 -### `dsh-fs-sandbox`——在提供方内部执行 +### `dsh-fs-sandbox`——在提供方内部强制执行 -`packages/fs/fs-sandbox/`(`@deepseek-ai/dsh-fs-sandbox`)镜像 `bash-local`/`bash-sandbox` 的拆分:`SandboxedFileSystem extends LocalFileSystem`,注册为 `ctx.fs`,注入 `sandboxPolicy`。读取(`resolve`/`stat`/`readText`/`streamText`/`listDir`)原样透传——每种模式都允许读。两个变更操作在委托给继承来的原子写之前按模式执行: +`packages/fs/fs-sandbox/`(`@deepseek-ai/dsh-fs-sandbox`)镜像 `bash-local`/`bash-sandbox` 的拆分:`SandboxedFileSystem extends LocalFileSystem`,注册为 `ctx.fs`,注入 `sandboxPolicy`。读取(`resolve`/`stat`/`readText`/`streamText`/`listDir`)原样透传——每种模式都允许读。两个变更操作在委托给继承来的原子写之前按模式强制执行: - `read-only` 直接拒绝 `writeText`/`editText`。 - `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 @@ -41,37 +41,37 @@ Status: implemented ### 工具对等——一个拒绝标记、一条升级流程 -`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。 +`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,向模型说明同样的同一轮次重试方式,并在执行前处理同样的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 Agent Note](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。 共享部分住在 `dsh-sandbox`,它拥有模式类型:`WIDER_MODES`、升级目标枚举、参数配对校验、拒绝/提示标记构造器,以及 `approveEscalation`——有序的 fail-closed 编排。`approveEscalation` 接收一个最小的结构化 approver(`EscalationApprover`,对 agent 与 call-id 类型泛型化),而非审批服务类型,所以 `dsh-sandbox` 不获得对 approval 或 agent 包的依赖:每个工具把自己的 `ctx.approval`、agent、call id 与工具名作为原料传入。`dsh-tool-bash` 与 `dsh-tool-fs` 都使用它们;跨文件重复检测门禁确保单一来源不走样。 -[`examples/acp-agent`](../../../../examples/acp-agent/cordis.yml) 组合加载 `dsh-sandbox-policy` 与 `dsh-fs-sandbox`,把 `mode`/`workspaceRoot` 配置移到策略条目,并去掉在受限模式下禁用整个 fs 栈的旧门控;`fs-policy`(read-before-edit)正交地叠加其上。系统提示仍然不陈述沙箱模式——标记会在真正重要的那一刻教会模型边界,依据沙箱 RFC 的线上证据。 +[`examples/acp-agent`](../../../../examples/acp-agent/cordis.yml) 组合加载 `dsh-sandbox-policy` 与 `dsh-fs-sandbox`,把 `mode`/`workspaceRoot` 配置移到策略条目,并去掉在受限模式下禁用整个 fs 栈的旧门控;`fs-policy`(read-before-edit)正交地叠加其上。系统提示仍然不陈述沙箱模式——标记会在真正重要的那一刻教会模型边界,遵循沙箱 Agent Note 所述的实时证据原则。 -### 执行点:提供方,而非 intent gate +### 强制执行点:提供方,而非 intent gate -沙箱 RFC 最初的跨家族草图把 fs 执行放在 `fs/write-intent`/`fs/edit-intent` 事件上。本 Agent Note 改为在提供方中执行,基于两个机制性事实:intent 槽是单决策、先到先得(已被 `dsh-fs-policy` 占据,其契约称第二个决策者为配置错误),且 intent 事件只由 `dsh-tool-fs` 派发——一个直连 `ctx.fs` 的调用方(一个 cordis 挂载插件、一个自定义工具)会绕过它们,而提供方级执行按构造覆盖每一个调用方。沙箱 RFC 的延后阶段措辞在同一变更中被更新以匹配。 +沙箱 Agent Note 最初的跨家族草图把 fs 强制执行放在 `fs/write-intent`/`fs/edit-intent` 事件上。本 Agent Note 改为在提供方中强制执行,基于两个机制性事实:intent 槽是单决策、先到先得(已被 `dsh-fs-policy` 占据,其契约称第二个决策者为配置错误),且 intent 事件只由 `dsh-tool-fs` 派发——一个直连 `ctx.fs` 的调用方(一个 cordis 挂载插件、一个自定义工具)会绕过它们,而提供方级强制执行按构造覆盖每一个调用方。沙箱 Agent Note 的暂缓阶段措辞在同一变更中被更新以匹配。 ### 范围之外 -- **`ctx.web` 的网络策略**——`SandboxMode` 只声明文件效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。 -- **`subagent-acp` 消费者**——沙箱 RFC 中未变的延后阶段。 +- **`ctx.web` 的网络策略**——`SandboxMode` 所声明的语义只涵盖文件系统效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。 +- **`subagent-acp` 消费方**——沙箱 RFC 中未变的延后阶段。 - **单个会话中的额外可写根目录**——解析后的策略携带一个主要 `SessionHeader.cwd`;ACP `additionalDirectories` 仍是独立的 bridge 与策略设计问题。 - **统一的 per-tool 沙箱运行时**——因沙箱 RFC 中的理由继续否决。 -## Alternatives considered +## 考虑过的替代方案 -- **在 `fs/*` intent 事件上执行(沙箱 RFC 的原始草图)**——因 § 执行点 中的两个机制性事实被否决:单槽先到先得且已被占据,以及对直连 `ctx.fs` 调用方的绕过。提供方级执行覆盖每一个调用方,并镜像 bash 的换实现形态。 -- **在 `tools/pre-execute` 中执行**——否决:监听器在 `resolve()` 之前看到模型的原始路径字符串,因此它会重新实现 cwd 默认化与符号链接规范化,并且仍与真正的 resolve 竞态。对 `workspace-write`(一个对规范路径的判定)而言是取消资格级的。 +- **在 `fs/*` intent 事件上强制执行(沙箱 RFC 的原始草图)**——因 § 执行点 中的两个机制性事实被否决:单槽先到先得且已被占据,以及对直连 `ctx.fs` 调用方的绕过。提供方级强制执行覆盖每一个调用方,并镜像 bash 的换实现形态。 +- **在 `tools/pre-execute` 中执行**——否决:监听器在 `resolve()` 之前看到模型的原始路径字符串,因此它会重新实现 cwd 默认化与符号链接规范化,并且仍与真正的 resolve 竞态。这使其不适用于 `workspace-write`,因为后者需要对规范路径作出判定。 - **在 `dsh-tool-fs` 中做内联检查**——否决:只覆盖工具路径(与 intent 事件同样的绕过),并在规范目标已存在之上重复了一层 resolve 知识。 - **在 `dsh-fs-local` 上加一个 `mode` 标志而非同级后端**——否决:能力事实必须是组合真相,正如 `dsh-bash-local` 对 `dsh-bash-sandbox`;一个配置标志会让工具的宣告取决于配置,而 bash 家族已经确立了同级包形态。 -- **经受限 helper 子进程做内核级 fs 变更**——否决:每次写一个进程;`editText` 的读-匹配-写临界区不得不整体搬进子进程才能保持原子;而威胁面(可信操作、不可信路径参数)不需要内核——可信代码中的围栏就是完整答案,而不可信代码隔离仍在 `ctx.bash`。 +- **经受限 helper 子进程做内核级 fs 变更**——否决:每次写入都要启动一个进程;`editText` 的读-匹配-写临界区不得不整体搬进子进程才能保持原子;而威胁面(可信操作、不可信路径参数)不需要内核——可信代码中的围栏就是完整答案,而不可信代码隔离仍在 `ctx.bash`。 - **带加载期一致性校验的 per-family 策略配置**——否决:一个事实两个归属,靠一个必须枚举每个未来执行家族的校验来打补丁;策略服务让漂移不可表达,而非被检测到。 - **把覆盖事件留在 `dsh-bash` 里作 `bash/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。 - **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构化 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。 - **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 - **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 -## Consequences +## 后果 已交付的部分——§ Testing 的各层各自钉住: @@ -82,17 +82,17 @@ Status: implemented - cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 - `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。 -- `agent-loop` 未被触动——一切都骑在 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并,以及工具执行管线之上。 +- `agent-loop` 未被触动——一切都依托于 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并以及工具执行流水线。 代价与接受的限制: - **fs 围栏是策略边界,而非内核边界。** 它的威胁面是模型选定的路径,而非对抗性宿主进程;resolve 到系统调用之间残留的 TOCTOU 被收窄而非消除,README 已如实声明。内核边界仍属 bash。 -- **`dsh-bash-sandbox` 获得对 `ctx.sandboxPolicy` 的硬依赖。** 每个沙箱化组合要么加一个 `cordis.yml` 条目,要么在加载时高声失败——这是有意的预发布奠基之举;示例在同一变更内更新。 +- **`dsh-bash-sandbox` 获得对 `ctx.sandboxPolicy` 的硬依赖。** 每个沙箱化组合要么加一个 `cordis.yml` 条目,要么在加载时明确报错——这是有意的预发布奠基之举;示例在同一变更内更新。 - **围栏与 runner 的对等是推导出来的,而非断言的。** fs 围栏与 Seatbelt profile 都从 `writableRoots` 取其可写集合,一个对等单元测试钉住这些集合;一个 runner profile 若在不经该函数的情况下改变其可写集合便会漂移。 - **标记与升级教学如今服务于两个家族。** 措辞改动是 `dsh-sandbox` 中一个构造器背后的协调编辑;重复检测门禁与钉住的快照维持单一来源,代价是 fs 与 bash 无法在不拆分该构造器的情况下有意地在措辞上分道。 -## Testing +## 测试 -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR(热模块替换)安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。 - 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index 910872881a..536f913800 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e -2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +2026-07-16-durable-per-step-time-context.md: e1a5c65894913ad93f46db8ae45e5ef5ead215f3 +2026-07-16-durable-per-step-time-context.zh.md: 9cbf653f5d43060e99502b640eed7907ffbd3840 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index 4bc17b3c08..e1a5c65894 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -12,9 +12,9 @@ A process-local refresh cache makes displayed time depend on state that cannot s ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when a reading is due and the downstream decision enters, returns one additional `UserMessage`. The message carries source `{ kind: 'plugin', plugin: 'time-context' }`; a suppressed, rejected, or failed attempt appends nothing. -The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback. +The listener samples before `step/start`, then settles its reading only in the final enter decision. AgentLoop records it after `step/start` and before request derivation. A downstream rejection or failure therefore prevents the reading from entering durable history. The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. @@ -44,7 +44,7 @@ Their baseline is the durable event timestamp of the preceding time-context mess Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place. -The plugin contributes nothing to system-prompt assembly. `request/header` contains no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because a failed preparation can leave a reading while interval suppression can prepare a request without appending one. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. +The plugin contributes nothing to system-prompt assembly. `request/header` contains no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because interval suppression can enter a request without appending a reading, while rejection or failure appends neither. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 836c0f83fb..9cbf653f5d 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。 +仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 -进程本地刷新缓存使显示的时间依赖无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 +进程本地刷新缓存会使显示时间依赖于一种既无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器;需要读数且下游决策 enter 时,返回一条额外的 `UserMessage`。该消息携带来源 `{ kind: 'plugin', plugin: 'time-context' }`;受间隔抑制、reject 或失败的尝试不会追加任何内容。 -监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。 +监听器在 `step/start` 之前采样,并只在最终 enter 决策中结算读数。AgentLoop 会在 `step/start` 之后、请求派生之前记录它。因此,下游 reject 或失败会阻止读数进入持久历史。 省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 @@ -44,11 +44,11 @@ Elapsed since the preceding step context: . 每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。 -插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为失败的准备过程可能留下读数,而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 +插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为间隔抑制可以让请求进入步骤而不追加读数,reject 或失败则两者都不追加。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 ## 测试 -单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 Loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。 +单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已中止信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index ad0592ae57..673e4869af 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 15b5ce7e20b7afc429f6ff7b8a4d2d69150c22a0 -2026-07-16-harness-level-loop.zh.md: a3fabe40d36c45c715f613ce8def35faa427d3bd +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +2026-07-16-harness-level-loop.md: 36a567204ee1082d48126369ee0b7277c9f24ca8 +2026-07-16-harness-level-loop.zh.md: cb8d29b4c77848613a6718ea3a343b0ee3294ea4 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index 15b5ce7e20..36a567204e 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -42,11 +42,11 @@ Time-based `/loop` or scheduled execution is a third policy and is not implement | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI. | | `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`, fixed workflow consumer | Registers `ralph({ objective, maxRounds? })`, validates the fresh structured provider and bounded `RalphRoundReport`, and returns `complete`, `blocked`, or `budget-limited`. | -The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. +The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [goal-owned event](../architecture/2026-07-31-goal-owned-durable-events.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. ### Durable goal state and live authority -One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. +One session has at most one current goal. Every mutation commits through a durable `goal/change` event carrying a full versioned snapshot or revisioned clear tombstone; inbox state does not participate. The session log is the only durable source of truth, so normal persistence, resume, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. @@ -58,9 +58,9 @@ Forked sessions inherit the durable goal prefix because that is the natural repl ### Same-session continuation -The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, pending mutations are durable, the exact goal id/revision/round still matches, and downstream prompt policy accepts it. The prompt-submit fence checks those facts both before and after asynchronous listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. +The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, the latest mutation has passed its durability checkpoint, the exact goal id/revision/round still matches, and downstream pre-step policy accepts it. Its `agent/pre-step` fence checks those facts both before and after downstream listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. -Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round. +Only an admitted positive-round goal-sourced `user/message` charges a round. A stale reservation closes a blocked no-step turn without consuming the cap. A concurrent goal revision wins over settlement from an older round. Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later authorize resume through ordinary language or `/goal resume`. @@ -122,7 +122,7 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella - **Aggregate budgets** — `maxGoalRounds` and Ralph `maxRounds` are the only aggregate effort limits. Token, currency, elapsed-time, provider-usage, and per-round price admission policies are absent. - **No persistent autonomous runner** — same-session goal facts persist, but activation and scheduling are process-local and deliberately wait for human input after restore. Ralph runs are foreground and cannot resume after process loss. Background collection, restart recovery, and unattended resident execution are deferred. - **No time scheduler** — interval `/loop`, cron, proactive maintenance, and cloud or desktop scheduling are outside this decision. -- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. +- **No generic loop journal or execution-world rewind** — session replay reconstructs goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. - **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. - **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. - **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index a3fabe40d3..cb8d29b4c7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -6,26 +6,26 @@ Status: implemented ## 问题 -具体 agent loop 只拥有一个 Turn:它排空已接纳输入,执行一个或多个模型与工具 Step,然后停止。大型目标通常需要一项外层策略来开始另一个 Turn、保留进度、在预算处停止,并让人类能够理解其状态。定时提示词、同会话续行和全新 agent Ralph 尝试都会重复工作,但它们并不共享相同的状态、权限、记忆或生命周期。 +具体 agent loop(智能体循环)只拥有一个轮次:它排空已接纳输入,执行一个或多个模型与工具步骤,然后停止。大型目标通常需要一项外层策略来开始另一个轮次、保留进度、在达到预算上限时停止,并让人类能够理解其状态。定时提示词、同会话续行和全新 agent Ralph 尝试都会重复工作,但它们并不共享相同的状态、权限、记忆或生命周期。 -若把每种重复动作都称为一个通用“loop”,就会掩盖这些差异。同会话工作必须在现有转录中持久化人类目标,同时保留对话上下文。Ralph 工作必须有意丢弃对话上下文,只使用工作区和一份有界交接。面向人类的状态不能暗示重新打开会话就会静默授权更多工作。完成与阻塞声明也需要显式信任边界,而不能被偷渡进调度器抽象。 +若把每种重复动作都称为一个通用「loop」,就会掩盖这些差异。同会话工作必须在现有 transcript(文本记录)中持久化人类目标,同时保留对话上下文。Ralph 工作必须有意丢弃对话上下文,只使用工作区和一份有界交接。面向人类的状态不能暗示重新打开会话就会静默授权更多工作。完成与阻塞声明也需要显式信任边界,而不能被偷渡进调度器抽象。 因此,本仓库需要位于 Turn/Step loop 之上的目标式执行,但不需要一个把持久化、评估、预算、调度、交接、后台任务和 UI 组合在一起的推测性通用 loop 服务。 ## 决策 -本提案以修订后的形式实现为构建在现有接缝之上的两项显式插件策略: +本提案以修订后的形式实现为构建在现有 seam 之上的两项显式插件策略: -1. **同会话目标**在当前会话中保留一个持久目标,并且只在实时激活态已激活时接纳带目标归属的续行 Turn。 +1. **同会话目标**在当前会话中保留一个持久目标,并且只在实时激活态已激活时接纳带目标归属的续行轮次。 2. **全新 agent Ralph 运行**执行一个固定前台工作流,其中每个 Round 都生成一个不带对话种子的全新结构化子 agent。 -系统中没有 `packages/loop/` 包族、`LoopDriver`、`LoopId`、通用 `StopCondition` 或面向模型的通用 `loop` 工具。两项策略共享本仓库普通的 agent、session、tools、workflow、subagent 与 UI 扩展接缝,但不会假装一种生命周期可以同时适配两者。 +系统中没有 `packages/loop/` 包族、`LoopDriver`、`LoopId`、通用 `StopCondition` 或面向模型的通用 `loop` 工具。两项策略共享本仓库普通的 agent、会话、工具、工作流、subagent 与 UI 扩展 seam,但不会假装一种生命周期可以同时适配两者。 ### 词汇与策略边界 -同会话层级是 **Goal → Goal Round → Turn → Step**。一个 Goal Round 是为当前目标接纳的一次续行周期,并实体化为一个带目标来源的 Turn。同一会话中的人类 Turn 或无关 Turn 不会消耗目标回合上限,而一个 Turn 仍可包含多个模型/工具 Step。 +同会话层级是 **Goal → Goal Round → 轮次 → 步骤**。一个 Goal Round 是为当前目标接纳的一次续行周期,并实体化为一个带目标来源的轮次。同一会话中的人类轮次或无关轮次不会消耗 Goal Round 上限,而一个轮次仍可包含多个模型/工具步骤。 -全新 agent 层级是 **Ralph Run → Ralph Round → fresh child Turn → Step**。一个 Ralph Round 创建一个子会话。父转录和此前子转录都不是种子上下文;共享工作区与一份有界结构化报告承载跨 Round 状态。 +全新 agent 层级是 **Ralph Run → Ralph Round → fresh child 轮次 → 步骤**。一个 Ralph Round 创建一个子会话。父 transcript 和此前子 transcript 都不是种子上下文;共享工作区与一份有界结构化报告承载跨 Round 状态。 因此,“Round”是外层策略迭代,不是每个会话 Turn 的同义词。具体 `dsh-agent-loop` 仍是 Turn/Step 引擎。同会话驱动器使用公开 agent 与 session 事件;它对核心唯一的新增项是通用的取消前观察通知 `agent/cancel-requested`,任何需要安全收敛取消的生命周期策略都可以使用它。 @@ -36,31 +36,31 @@ Status: implemented | 包 | 仓库类别 | 所属结构与动词 | |---|---|---| | `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、四状态 `GoalPhase`、结构化 `GoalBlockReason`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`clear` 与 `disarm` 动词。 | -| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到带机器可路由原因代码的完成或阻塞报告。 | -| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费方 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到带机器可路由原因代码的完成或阻塞报告。 | +| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并排空同会话 Goal Round,直至完全停稳。 | | `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 | | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 | -| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费者 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 | +| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费方 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 | -详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 +详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[Goal 自有事件](../architecture/2026-07-31-goal-owned-durable-events.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 ### 持久目标状态与实时权限 -一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。 +一个会话至多有一个当前 goal。每次变更都通过持久 `goal/change` 事件提交,并携带带版本的完整快照或带修订号的 clear 墓碑;inbox 状态不参与其中。会话日志是唯一持久真源,因此普通持久化、恢复与 `SessionStore.fork()` 会携带 goal,无需第二个数据库或人为取消记录。 -持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 +持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 回放、驱动器替换和驱动器拆卸都会让目标保持未激活。 这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。 fork 会话会继承持久目标前缀,因为这是自然的重放结果。fork 从未激活状态开始,因此继承不等于执行权限,历史中也不会插入合成目标取消。 -`defaultMaxGoalRounds` 可配置且默认为 `256`。该上限只计算已接纳目标回合。`blockedAfterConsecutiveRounds` 在模型工具策略中单独配置且默认为 `3`;它只是在自治 Round 报告重复阻塞前的机械下限,不是对语义相同性的评估器。 +`defaultMaxGoalRounds` 可配置且默认为 `256`。该上限只计算已接纳的 Goal Round。`blockedAfterConsecutiveRounds` 在模型工具策略中单独配置且默认为 `3`;它只是在自治 Round 报告重复阻塞前的机械下限,不是对语义相同性的评估器。 ### 同会话续行 -目标回合驱动器为每个准确实时 agent 至多拥有一个待定预留。只有目标处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、待定变更已经持久、准确目标 id/修订号/Round 仍匹配,并且下游提示词策略接受时,它才会接纳预留。prompt-submit 围栏在异步监听器前后都检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳过时工作。 +Goal Round 驱动器为每个准确实时 agent 至多拥有一个待定预留。只有 goal 处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、最新变更已经通过持久性检查点、准确 goal id/revision/Round 仍匹配,并且下游 pre-step 策略接受时,它才会接纳预留。其 `agent/pre-step` 围栏会在下游监听器前后检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳陈旧工作。 -只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。 +只有已准入、Round 为正数且来源为 goal 的 `user/message` 会计入一个 Round。陈旧预留会关闭一个 blocked 的无步骤轮次,不会消耗上限。并发 goal revision 会胜过旧 Round 的结算。 普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个 Turn 内重试暂时性 provider 失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 @@ -68,21 +68,21 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for 人类 UX 遵循 [OpenAI Codex 在提交 `678157a` 时的公开 TUI 分发器](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)中的紧凑形态:`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。该提交永久链接让研究所得语法在 Codex 演进时仍可验证。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。 -模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 +模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为确切的当前 Goal Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP 挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI 与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 +TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。 -每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。Ralph 还会把解析后的 Round 上限作为 `WorkflowStartRequest.maxTotalAgents` 传递;工作线程引擎会在发布工作前验证两项每次运行策略,因此 provider 配置错误或低于所请求 Ralph 规模的引擎上限会在运行存在前失败。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 +每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该提供方必须存在、支持结构化输出,并声明不继承父上下文。Ralph 还会把解析后的 Round 上限作为 `WorkflowStartRequest.maxTotalAgents` 传递;工作线程引擎会在发布工作前验证两项每次运行策略,因此 provider 配置错误或低于所请求 Ralph 规模的引擎上限会在运行创建前失败。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 -报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。`maxResultChars` 单独默认为 `16384`,并限制面向父级的完整成功文本,包括外层文本与截断标记。 +报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费方边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。`maxResultChars` 单独默认为 `16384`,并限制面向父级的完整成功文本,包括外层文本与截断标记。 普通子 agent 失败会结束运行且不重试。固定脚本会报告失败 Round,并在存在时带回上一份成功交接;工具会把该状态作为错误返回,而不会误判为畸形报告或预算耗尽。致命工作流基础设施错误可能在脚本返回该状态前结算;更丰富的原因传输与重试策略均予以延期。 -该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用,以及一份有界成功终止结果或一个错误;完成与阻塞的外层文本会明确说明结果由工作者报告,而不会呈现为独立认证。中间子 agent 对话不会进入父转录。 +该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已完全停稳。模型只看到一次调用,以及一份有界成功终止结果或一个错误;完成与阻塞的外层文本会明确说明结果由工作者报告,而不会呈现为独立认证。中间子 agent 对话不会进入父 transcript。 ### 外部设计谱系 @@ -90,40 +90,40 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 当前 [Claude Code goals](https://code.claude.com/docs/en/goal) 进一步验证了“前一 Turn 后启动另一 Turn 的目标”和定时 `/loop` 之间的区别。Claude Code 还会在每个 Turn 后使用独立小模型评估器。本实现采用策略区分,但有意不复制该评估器:评估器输入、工具访问、确定性检查、provider 选择、隔离与权限需要单独设计的插件契约,而不是隐式自我认证层。 -外部产品只是比较对象,不是兼容目标。本地源码研究帮助确定边界,而交付接口遵循本仓库“一切皆插件”、模型可见即可记录、显式解析默认值与静止拆卸规则。 +外部产品只是比较对象,不是兼容目标。本地源码研究帮助确定边界,而交付接口遵循本仓库“一切皆插件”、模型可见内容均记入日志、显式解析默认值与完全停稳后拆卸规则。 ### 验证 -六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消,以及通过无头应用执行两个真实 Ralph Round;聚焦的命令测试固定了无需模型 Turn 的直接 `/goal` 状态。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖仅包级或仅模拟夹具的覆盖。 +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖率。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、确切的 Goal Round 归属、适配器范围的命令发现与 transcript 隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消,以及通过无头应用执行两个真实 Ralph Round;聚焦的命令测试固定了无需模型 Turn 的直接 `/goal` 状态。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和精确的单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后子 agent 完全停稳。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR(Pull Request)中携带真实示例无密钥快照,而不能依赖仅包级或仅 mock fixture(测试前置数据)的覆盖率。 ## 考虑过的替代方案 -- **实现原始通用 loop 能力接缝**——不予采纳,因为 `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector`、后台任务所有权、持久化与调度并不构成一项一致的必选抽象。在出现首个具体消费者前全部构建,会产生宽泛推测性表面,并重复现有 session、workflow、subagent 与 task 机制。 +- **实现原始通用 loop 能力 seam**——不予采纳,因为 `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector`、后台任务所有权、持久化与调度并不构成一项一致的必选抽象。在出现首个具体消费者前全部构建,会产生宽泛推测性表面,并重复现有 session、workflow、subagent 与 task 机制。 - **只实现同会话目标**——不予采纳,因为全新上下文迭代在实质上不同,也是插件架构的重要示范。Ralph 应作为带显式上下文重置的固定工作流消费者。 - **把 Ralph 放进目标回合驱动器**——不予采纳,因为同会话目标有意保留一段对话,而 Ralph 有意移除对话。合并两者会让激活、重放、交接与 UI 状态含糊不清。 - **把 fork 当成全新 Ralph 子 agent**——不予采纳,因为 fork 会携带对话前缀。全新子 agent 加工作区状态与一份显式报告更容易限制和重放,并且无需合成取消记录。 -- **把 Claude Code 评估器复制进首个目标实现**——不予采纳,因为只读取转录的模型评估器是一项有用策略,但不是普遍可信的完成证书。系统必须仍能支持确定性评估与隔离,因此评估器延期到其权限与 provider 接缝完成设计之后。 +- **把 Claude Code 评估器复制进首个目标实现**——不予采纳,因为只读取转录的模型评估器是一项有用策略,但不是普遍可信的完成证书。系统必须仍能支持确定性评估与隔离,因此评估器延期到其权限与 provider seam 完成设计之后。 - **会话恢复后自动续行**——不予采纳,因为打开会话是观察行为,不是花费资源的权限。系统恢复持久状态,而激活态等待新的人类提示词。 -- **通过模型路由 `/goal`**——不予采纳,因为状态与显式生命周期控制应是确定、零 token 的 UI 操作;普通自然语言提示词仍是语义模型路径。 -- **为具体 agent loop 增加目标或 Ralph 模式**——不予采纳,因为公开队列、提示词、会话、取消、工作流与 subagent 接缝已经支持两项策略。通用 cancel-requested 观察是唯一核心协调新增项。 +- **通过模型路由 `/goal`**——不予采纳,因为状态与显式生命周期控制应是确定性且零 token 的 UI 操作;普通自然语言提示词仍是语义模型路径。 +- **为具体 agent loop 增加目标或 Ralph 模式**——不予采纳,因为公开队列、提示词、会话、取消、工作流与 subagent seam 已经支持两项策略。通用 cancel-requested 观察是唯一核心协调新增项。 ## 后果 -- 目标式执行在没有单个过载“loop”对象的情况下交付:同会话续行与全新 agent 迭代拥有显式、可独立测试的契约。 +- 目标式执行在没有单个过载「loop」对象的情况下交付:同会话续行与全新 agent 迭代拥有显式、可独立测试的契约。 - 持久目标历史可以重放和 fork,而进程本地激活态会防止恢复时意外开始工作。 - 人类获得小型 Codex 形态 UX;模型获得紧凑、带来源检查的工具表面;部署可以独立移除任一能力。 - Ralph 展示了非平凡固定策略可以完全作为现有 workflow 与 subagent 原语之上的插件实现。 - Round 上限默认宽裕,但仍由部署控制。它限制迭代次数,不限制 token、价格、耗时或外部副作用。 - 原始提案中的评估器、预算、反思器、后台任务、CLI 与通用 loop-session 架构有意不进入已实现公开表面。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - **独立评估**——同会话完成/阻塞和 Ralph 终止状态都是模型或工作者声明。独立评估器、评估器驱动反馈 Round、完成证书、确定性检查器、对抗式 verifier 与 criteria/executor/isolation 契约均予以延期。 - **聚合预算**——`maxGoalRounds` 与 Ralph `maxRounds` 是唯一聚合工作量限制。token、货币、耗时、provider 用量与逐 Round 价格准入策略均不存在。 - **没有持久自治运行器**——同会话目标事实会持久化,但激活与调度只存在于进程内,并且有意在恢复后等待人类输入。Ralph 位于前台,进程丢失后无法恢复。后台收集、重启恢复与无人值守常驻执行均予以延期。 - **没有时间调度器**——间隔 `/loop`、cron、主动维护以及云端或桌面调度不在本决策范围内。 -- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 +- **没有通用 loop 日志或执行世界回退**——会话重放会重建目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 - **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 -- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 -- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。 +- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与从结构上禁止递归调用 Ralph 工具都需要独立策略表面。提示词指导不是强制执行。 +- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与 seam 设计。 - **可移植 UI 仍较朴素**——TUI 渲染纯文本目标状态和通用 Ralph 卡片。ACP 只承载已提交的助手文本;系统没有持续状态组件、可重连命令输出、模态目标编辑器,ACP、无头 CLI 与 JSON-RPC 也没有命令平面。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 2de2288506..451a4ea0d3 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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/feature/2026-07-16-persistent-pty-sessions.md 2026-07-16-persistent-pty-sessions.md: d7d06dc8517780a37889e4f94dd0b99475dc5432 -2026-07-16-persistent-pty-sessions.zh.md: 7783fa4b4f3056ab3b3088a4e9618f45d030ccbf +2026-07-16-persistent-pty-sessions.zh.md: 84ef8988ba657a87137f904df38e45808d32b483 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 7783fa4b4f..84ef8988ba 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -14,7 +14,7 @@ harness 可以运行前台与后台命令、编辑文件和委派工作,但无 ## 决策 -可选的 `packages/pty/` 功能家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。 +可选的 `packages/pty/` 能力家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。 当前实现在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟。 @@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后 实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。 -agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端 reject,或返回的会话在回滚 close 时失败,调用方取消仍会原样保留其 `AbortSignal.reason`;该清理失败不会替换调用方原因,而会继续受追踪,留待后续 owner 或服务 dispose 处理。由 lifecycle dispose 触发的回滚 close 失败会使 spawn 与该 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换调用方取消的前提下,为该 lifecycle dispose 保留自身的启动清理失败。若调用方取消先于 dispose 完成结算,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为静默。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。 +agent scope dispose(资源释放)时先关闭注册,再等待全部所属 PTY 完全停稳。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端 reject,或返回的会话在回滚 close 时失败,调用方取消仍会原样保留其 `AbortSignal.reason`;该清理失败不会替换调用方原因,而会继续受追踪,留待后续 owner 或服务 dispose 处理。由 lifecycle dispose 触发的回滚 close 失败会使 spawn 与该 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换调用方取消的前提下,为该 lifecycle dispose 保留自身的启动清理失败。若调用方取消已完成结算,而 dispose 尚未发生,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为完全停稳。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。 ### 安全与进程边界 @@ -55,7 +55,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 | `terminal_send` | 发送文本、可选提交 Enter,并等待就绪或注册一个后台任务 | 有界 viewport、等待状态和会话状态;后台模式还返回 `taskId` | | `terminal_read` | 从保留的 scrollback 读取一个有界页 | `{ text, totalLines, lineBegin, lineEnd, truncated }` | | `terminal_signal` | 向当前前台进程组发送一种允许的信号 | `{ delivered, targetPgid }` | -| `terminal_close` | 关闭一个会话并等待进程树静默退出 | `{ killed }` | +| `terminal_close` | 关闭一个会话并等待进程树完全停稳 | `{ killed }` | | `terminal_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 | UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发送使用 terminal 调用卡片和结果卡片;后台形式使用通用 `execute` 卡片。`terminal_open`、`terminal_read`、`terminal_signal`、`terminal_close` 和 `terminal_list` 分别使用通用 `execute`、`read`、`execute`、`delete` 和 `read` 卡片。所有 PTY 工具都不发出 `locations`。 @@ -66,7 +66,7 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发 当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。生产方把 `maxResultBytes` 写入 task 快照,使 `task_output`、kill 返回的终态状态和完成通知在加上通用元数据后,仍对完整结果执行同一上限。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 会解析当前前台 PGID 并发送真正的 `SIGINT`,即使应用已禁用终端 `ISIG` 也同样如此,且后续升级仍只通过 PTY 后端拥有的 teardown 路径进行。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。 -`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和返回页载荷执行行数与 UTF-8 字节上限,因此单个超长行无法绕过后端上限;工具随后再限制包含分页与截断元数据的完整渲染页。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 +`terminal_read` 从最新保留行起,朝更早的行分页。后端同时对保留的 scrollback 和返回页载荷执行行数与 UTF-8 字节上限,因此单个超长行无法绕过后端上限;工具随后再限制包含分页与截断元数据的完整渲染页。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 `terminal_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `terminal_close`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。 @@ -76,7 +76,7 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发 在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 -macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 +macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上进行单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 @@ -88,13 +88,13 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 现有持久化 `tool/call` 与 `tool/result` 事件是模型发送文本和返回给模型的渲染输出的真源。`terminal_open` 通过已记录的工具结果返回 MOTD;前台 `send`/`read`/`list`/`signal`/`close` 结果走同一路径记录。PTY 包不会把原始字节流重复写入自定义会话事件。 -后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript sink 必须拥有独立的保留、凭证和隐私契约。 +后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript(文本记录)sink 必须拥有独立的保留、凭证和隐私契约。 ### 进程树 teardown -顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获其传递子进程、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向剩余子孙进程树发送 `SIGKILL`,并在 shell 仍存活时验证每个非僵尸子孙进程都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为静止;shell 关闭时会回收它或将其重新挂接给负责回收的父进程。完成这些步骤后,后端才用 shell 自身的 TERM、宽限等待、KILL 序列停止 shell。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 +顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获其传递子进程、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向剩余子孙进程树发送 `SIGKILL`,并在 shell 仍存活时验证每个非僵尸子孙进程都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳;shell 关闭时会回收它或将其重新挂接给负责回收的父进程。完成这些步骤后,后端才用 shell 自身的 TERM、宽限等待、KILL 序列停止 shell。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 -teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树中不再存在非静止成员后才完成,否则返回清理失败并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此外部存活进程状态改变后,后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。 +teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树中不再存在尚未完全停稳的成员后才完成,否则返回清理失败并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此外部存活进程状态改变后,后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。 ### 组合与推行 @@ -155,8 +155,8 @@ plugins: ## 验证 -- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 -- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 逐文件测试覆盖并固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 +- Linux 进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、僵尸进程的完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。 @@ -178,4 +178,4 @@ plugins: **进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。 -**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行 built-artifact smoke。 +**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行构建产物冒烟测试。 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml index 67b6b8da3d..26b8332442 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md 2026-07-19-fresh-agent-ralph-workflow-tool.md: 6fe96587c49ef0316d1618fda2ee26b015b1ce87 -2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: c615c16ac020a0c905f6c8b52d8dc487fbcfe8be +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 03e5e4a705674b49c14cbb456b06d82fbd38f013 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md index c615c16ac0..03e5e4a705 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -6,69 +6,69 @@ Status: implemented ## 问题 -同会话目标会保留对话,让一个 agent 持续完成持久目标;通用工作流工具则让模型编写扇出编排脚本。两者都不是 Ralph 模式:把同一目标反复交给完全全新的工作者,以共享工作区作为长期记忆,并且在各轮之间只传递一份小型显式交接,直到工作完成或触及限制。 +同会话目标会保留对话,让一个 agent 持续完成持久目标;通用工作流工具则让模型编写扇出编排脚本。两者都不是 Ralph 模式:把同一目标反复交给完全全新的工作者,以共享工作区作为长期记忆,并且在各 Round 之间只传递一份小型显式交接,直到工作完成或触及限制。 -如果把 Ralph 行为加入 `dsh-agent-loop`、目标驱动器或面向模型的公开工作流语言,就会让一项策略与无关的执行机制耦合。让每个子 agent 继承父对话也会破坏上下文重置,并让重放依赖不断增长的隐式前缀。此功能需要一项由现有插件原语组合而成的固定、可评审策略,同时具备取消静止性、有界跨轮数据、宽裕且可配置的上限,并且不引入新颖的面向人类目标状态。 +如果把 Ralph 行为加入 `dsh-agent-loop`、目标驱动器或面向模型的公开工作流语言,就会让一项策略与无关的执行机制耦合。让每个子 agent 继承父对话也会破坏上下文重置,并让重放依赖不断增长的隐式前缀。此功能需要一项由现有插件原语组合而成的固定、可评审策略,同时保证取消后完全停稳、有界跨 Round 数据、宽裕且可配置的上限,并且不引入新的面向人类目标状态。 ## 决策 -在 `packages/workflow/` 下新增独立消费者包 `@deepseek-ai/dsh-tool-ralph`。它注册 `ralph({ objective, maxRounds? })`,拥有固定工作流脚本,并且只依赖 `ctx.tools`、`ctx.systemPrompt`、`ctx.workflows` 和 `ctx.subagents`。Ralph 运行不是会话目标,不会创建目标状态,也不要求在具体 agent loop 中增加分支。 +在 `packages/workflow/` 下新增独立的消费方包 `@deepseek-ai/dsh-tool-ralph`。它注册 `ralph({ objective, maxRounds? })`,拥有固定工作流脚本,并且只依赖 `ctx.tools`、`ctx.systemPrompt`、`ctx.workflows` 和 `ctx.subagents`。Ralph 运行不是会话目标,不会创建目标状态,也不要求在具体 agent loop 中增加分支。 -该工具仅以前台方式运行。调用 agent 作为每个子 agent 的父级以提供 cwd 和谱系,父工具调用等待整次运行结束,父步骤的中止信号会取消工作流。每条路径都会等待 `run.dispose()`,因此调用返回前,取消会经过工作流引擎的有界收敛并达到子 agent 静止状态。 +该工具仅以前台方式运行。调用 agent 作为每个子 agent 的父级以提供 cwd 和谱系,父工具调用等待整次运行结束,父步骤的中止信号会取消工作流。每条路径都会等待 `run.dispose()`,因此调用返回前,取消会经过工作线程引擎的有界收敛并使子 agent 完全停稳。 -### 每次运行的工作流 provider 路由 +### 每次运行的工作流提供方路由 -`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider;在发布运行前,它要求所选规范化路由已注册,并把结果用于每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。 +`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的提供方;在发布运行前,它要求所选规范化路由已注册,并把结果用于每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和提供方策略保持不变。 -Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。 +Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名提供方已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的提供方会在工作流启动前明确报错。提供方查找保留在调用期,因为效果作用域内的提供方注册可能随 HMR(热模块替换)改变。 ### 每次运行的工作流子 agent 上限 -`WorkflowStartRequest` 还新增可选的 `maxTotalAgents`。工作线程引擎要求它是正安全整数且不高于已配置的部署上限,并在发布运行前把解析值装入该运行的工作线程限制。Ralph 把解析后的 `maxRounds` 作为此上限,因此固定循环的轮次预算不会与通用失控子 agent 后备限制冲突。普通工作流工具不设置该字段并保留引擎默认值。 +`WorkflowStartRequest` 还新增可选的 `maxTotalAgents`。工作线程引擎要求它是正安全整数且不高于已配置的部署上限,并在发布运行前把解析值装入该运行的工作线程限制。Ralph 把解析后的 `maxRounds` 作为此上限,因此固定循环的 Round 预算不会与通用失控子 agent 后备限制冲突。普通工作流工具不设置该字段并保留引擎默认值。 -### Ralph 轮次与交接 +### Ralph Round 与交接 -层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。 +层级为 Ralph 运行 → Ralph Round → 全新子 agent 轮次 → 步骤。每个 Ralph Round 恰好通过所选提供方创建一个子 agent。spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。 -固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。 +固定提示只传递不可变目标、当前 Round 与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费方还会跨 workflow seam 再次验证实体化的终止值。 -`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 和 `maxResultChars` 均默认为 `16384`。三者都是正安全整数配置值。过大的交接会失败,而不会被静默截断;`maxResultChars` 单独限制面向父级的完整成功文本,包括外层文本和截断标记,并且不会改变跨轮状态。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。 +`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 和 `maxResultChars` 均默认为 `16384`。三者都是正安全整数配置值。过大的交接会失败,而不会被静默截断;`maxResultChars` 单独限制面向父级的完整成功文本,包括外层文本和截断标记,并且不会改变跨 Round 状态。最后一个允许的 Round 报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动的 Round 数。 -工作流语言会把正常结束但未成功的子 agent 映射为 `null`。固定脚本会在报告验证前检测该值,并返回 `round-failed`,其中包含失败轮次,以及存在时的上一份成功交接;工具会把它转成错误,而不会误判为畸形报告或预算耗尽。Ralph 不添加重试策略。致命的 provider 启动、传输、工作线程和工作流错误仍是通用工作流失败,因为这些路径上的工作流接缝不携带可恢复的子报告。 +工作流语言会把正常结束但未成功的子 agent 映射为 `null`。固定脚本会在报告验证前检测该值,并返回 `round-failed`,其中包含失败的 Round,以及存在时的上一份成功交接;工具会把它转成错误,而不会误判为畸形报告或预算耗尽。Ralph 不添加重试策略。致命的提供方启动、传输、工作线程和工作流错误仍是通用工作流失败,因为这些路径上的 workflow seam 不携带可恢复的子报告。 ### 模型与 UI 表面 -模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。 +模型只能提供 `objective` 和可选的 `maxRounds`;提供方选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接交互的人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。 -面向人类的展示使用通用 `ralph` 卡片,并把目标作为原始输入;ACP 只承载已提交的助手文本。成功完成与阻塞的外层文本会说明结果由工作者报告,而不会把它呈现为独立认证。父转录只保留原始工具调用,以及一份有界成功终止报告或一个错误,不包含中间子 agent 消息。发布的无头、TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 +面向人类的展示使用通用 `ralph` 卡片,并把目标作为原始输入;ACP(Agent Client Protocol)只承载已提交的助手文本。成功完成与阻塞的外层文本会说明结果由工作者报告,而不会把它呈现为独立认证。父级 transcript(文本记录)只保留原始工具调用,以及一份有界成功终止报告或一个错误,不包含中间子 agent 消息。发布的无头、TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 ## 测试 -单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 +单元测试覆盖配置与调用上限解析、提供方能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、dispose(资源释放)、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明提供方路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且提供方覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 -一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式转录,并检查持久化日志中存在两个不同且无种子的子会话,且第一轮交接只出现在第二轮。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 +一项无密钥真实栈集成测试通过实际工作线程引擎、spawn 提供方、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一 Round 的交接中、只产生一个阶段事件、终止完成以及两个子 agent 都已 dispose。同一真实栈还覆盖阻塞与 Round 上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后子 agent 完全停稳。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式 transcript,并检查持久化日志中存在两个不同且无种子的子会话,且 Round 1 的交接只出现在 Round 2。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导 transcript 表面。 ## 考虑过的替代方案 -- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为目标轮次有意保留同一段对话,而 Ralph 的定义性属性是每轮使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。 -- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与 provider 无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费者。 -- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成回合是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。 -- **让工具直接调用 subagent 接缝** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和静止处置。复用它可以展示插件组合,而不是构建第二个循环运行时。 +- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为 Goal Round 有意保留同一段对话,而 Ralph 的定义性属性是每个 Round 使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。 +- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与提供方无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费方。 +- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成轮次是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。 +- **让工具直接调用 subagent seam** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和完全停稳后的 dispose。复用它可以展示插件组合,而不是构建第二个循环运行时。 - **静默截断大型报告** — 拒绝,因为截断可能删除状态证据或下一步,却仍看似权威交接。生产者必须在配置边界内发出有效报告。 ## 后果 -- 全新 agent 迭代成为一项一等模型工具,并完全以现有接缝之上的可移除插件实现。 -- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。 -- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。 -- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 -- provider 路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 +- 全新 agent 迭代成为一项一等模型工具,并完全以现有 seam 之上的可移除插件实现。 +- Goal Round 与 Ralph Round 保持不同概念:前者是一次同会话续行轮次,后者是前台工作流中的一个全新子 agent。 +- 工作区成为权威跨 Round 记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。 +- 宽裕的 Round 上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 +- 提供方路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 -## 已知限制与推迟工作 +## 已知限制与暂缓事项 -- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈轮次、完成证书或对抗式 verifier 被有意推迟。 +- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈 Round、完成证书或对抗式 verifier 被有意推迟。 - 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。 -- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。 -- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。 -- 普通子 agent 失败会结束运行且不重试,同时保留失败轮次与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与接缝设计。 +- Round 数是唯一聚合预算。token、货币、耗时和提供方用量预算仍属于未来的独立策略。 +- 每个 Round 创建一个子 agent。Round 内扇出、evaluator/工作者角色分离、动态提供方或模型选择,以及跨运行日志均被推迟。 +- 普通子 agent 失败会结束运行且不重试,同时保留失败 Round 与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与 seam 设计。 - 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 44e1412cc4..60cf540d45 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-human-goal-command.md: ce5c37fd28f9432d8c9a8797cac32c632617e317 -2026-07-19-human-goal-command.zh.md: d5ce36bd75a6f1070ee1eaeb1ac6ee97778c246b +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-human-goal-command.md +2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 +2026-07-19-human-goal-command.zh.md: 74dd0a2bec923687c91ddf4ce7efe4cc5b25bfc9 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index ce5c37fd28..5fdd80f742 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -34,7 +34,7 @@ Status output omits branded ids and compare-and-set revisions because those are Expected `GoalError` failures become one stable, branded-id-free `CommandResult.error`, so domain diagnostics do not leak compare-and-set internals into the human surface and invalid operations never enter model history. The current status supplies the actionable state-specific recovery. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. -Generic slash input, status text, and errors are not persisted. Successful goal mutations use the existing `Agent.inject()` path, producing the raw model-visible goal snapshot or clear tombstone that persistence already owns. The command therefore changes no session format and introduces no second audit record that could disagree with the domain event. +Generic slash input, status text, and errors are not persisted. Successful goal mutations append the domain-owned `goal/change` event and do not queue model context. The command introduces no second audit record that could disagree with the domain event. ### App composition diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index d5ce36bd75..74dd0a2bec 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -同会话目标领域和模型工具提供了状态机与自然语言语义路径,但尚不足以构成面向人类的 UX。用户需要在不询问模型的情况下检查准确的当前阶段与回合预算,在不消耗模型轮次的情况下明确暂停或清除工作,并在会话恢复后经过必要的人类决策重新激活已恢复的活跃目标。若在各 UI 中分别实现这些操作,就会重复解析逻辑、导致各界面发生偏差,还可能把未知或不可用的命令交给模型处理。 +同会话目标领域和模型工具提供了状态机与自然语言语义路径,但尚不足以构成面向人类的 UX。用户需要在不询问模型的情况下检查准确的当前阶段与 Round 预算,在不消耗模型轮次的情况下明确暂停或清除工作,并在会话恢复后经过必要的人类决策重新激活已恢复的活跃目标。若在各 UI 中分别实现这些操作,就会重复解析逻辑、导致各界面发生偏差,还可能把未知或不可用的命令交给模型处理。 -该命令还必须遵守目标设计中的两类状态。持久阶段、目标描述、修订号与回合来自会话日志;进程本地激活态决定活跃目标能否自动继续。恢复后若只显示“活跃”,就会掩盖目标已被有意设为未激活、正在等待人类授权这一事实。 +该命令还必须遵守目标设计中的两类状态。持久阶段、目标描述、修订号与 Round 来自会话日志;进程本地激活态决定活跃目标能否自动继续。恢复后若只显示“活跃”,就会掩盖目标已被有意设为未激活、正在等待人类授权这一事实。 ## 决策 位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它注册一个全局 `goal` 定义,因此组合中的每个命令适配器都会发现同一个命令;不兼容的应用应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 -该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 +该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、Round 计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 ### 语法与生命周期动词 @@ -22,9 +22,9 @@ Status: implemented `/goal ` 创建活跃且已激活的目标。已完成目标可以被替换,此时通过现有领域规则创建新的目标身份。任何未完成目标都会让命令直接失败,并提示用户使用行内编辑或明确清除。通用命令服务有意不提供模态确认 API;若静默执行清除再创建两条持久记录,就等于凭空制造破坏性同意,并暴露一个非原子的失败窗口。 -`/goal edit ` 编辑当前未完成目标,但不改变其阶段或激活态。若目标已经完成,则创建一个新的活跃目标,因为领域不允许恢复已完成状态,而新的完成条件应拥有新的目标身份。单独使用 `edit` 会返回错误而不是启动编辑器,因为可移植的非结构化命令契约没有模态编辑器。 +`/goal edit ` 编辑当前未完成目标,但不改变其阶段或激活态。若目标已经完成,则创建一个新的活跃目标,因为领域不允许恢复已完成状态,而新的目标描述应拥有新的目标身份。单独使用 `edit` 会返回错误而不是启动编辑器,因为可移植的非结构化命令契约没有模态编辑器。 -`/goal pause`、`/goal resume` 与 `/goal clear` 使用当前视图调用相应的比较并交换领域动词。恢复既适用于停止的持久阶段,也适用于会话恢复、fork 或驱动器替换后处于活跃但未激活状态的目标。领域规则仍会拒绝已耗尽的回合上限、对已活跃且已激活目标的重复恢复、非法阶段转换与陈旧身份。清除会移除当前指针,而会话日志保留带修订号的墓碑和此前快照。 +`/goal pause`、`/goal resume` 与 `/goal clear` 使用当前视图调用相应的比较并交换领域动词。恢复既适用于停止的持久阶段,也适用于会话恢复、fork 或驱动器替换后处于活跃但未激活状态的目标。领域规则仍会拒绝已耗尽的 Round 上限、对已活跃且已激活目标的重复恢复、非法阶段转换与陈旧身份。清除会移除当前指针,而会话日志保留带修订号的墓碑和此前快照。 控制词会在去除两端空白后按 ASCII 大小写不敏感方式匹配。只有占据完整后缀时才被视为控制;其余任何非空文本都是目标描述。这保持了可预测的自由形式命令规则:`/goal pause after verification` 是目标描述,而不是被部分解析的暂停命令。 @@ -32,41 +32,41 @@ Status: implemented 状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续;被阻塞的目标还会包含其持久策略代码和面向人类的说明。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或已暂停/被阻塞目标提供恢复,已完成目标则提供替换或清除。 -预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向人类表面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 +预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向面向人类的界面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 -通用斜杠输入、状态文本与错误不会持久化。成功的目标变更使用现有 `Agent.inject()` 路径,产出持久化本就拥有的原始模型可见目标快照或清除墓碑。因此该命令不会改变会话格式,也不会引入可能与领域事件不一致的第二份审计记录。 +通用斜杠输入、状态文本与错误不会持久化。成功的 goal 变更会追加领域自有的 `goal/change` 事件,而且不会把模型上下文排队。该命令不会引入可能与领域事件不一致的第二份审计记录。 ### 应用组合 -`agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在一个相关物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 +`agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在与调用关联的一个物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方;`goals: false` 会一致地移除整个栈。[ACP(Agent Client Protocol)自动化应用](../simplification/2026-07-23-acp-automation-only-protocol.md)也默认挂载目标领域与模型工具,但有意省略命令服务。Python SDK 运行时闭包交付本生产方、命令与目标栈,使外部 `cordis.yml` 能组合相同命令。 ## 测试 -生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致退出、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。 +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、dispose(资源释放)、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、无目标状态下的所有控制命令、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致停用、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。 ## 考虑过的替代方案 - **让模型把 `/goal` 当作普通文本处理**——不予采纳,因为状态与直接生命周期操作会消耗模型轮次、可能被重新解释,也无法提供确定性的命令发现。 - **在各 UI 中分别实现处理器**——不予采纳,因为语法、错误行为与目标状态格式会发生偏差,可选部署也无法把该功能作为一个 effect 统一增删。 -- **为 `ctx.commands` 添加模态编辑与替换确认**——不予采纳,因为现有跨表面契约是非结构化输入加直接输出;通用交互协议所需的设计远超这一个生产方。 +- **为 `ctx.commands` 添加模态编辑与替换确认**——不予采纳,因为现有跨界面契约是非结构化输入加直接输出;通用交互协议所需的设计远超这一个生产方。 - **静默替换未完成目标**——不予采纳,因为这会在没有原子性或明确破坏性意图的情况下组合清除与创建。 -- **在人类状态中暴露目标 id 与修订号**——不予采纳,因为人类操作始终在一个同步处理器内针对准确当前视图;这些字段只会增加实现噪声,无法消除其他竞争。 +- **在人类状态中暴露目标 id 与修订号**——不予采纳,因为人类操作始终在一个同步处理器内针对确切的当前视图;这些字段只会增加实现噪声,无法避免另一处竞态。 - **在无 UI 主干中无条件启用目标**——不予采纳,因为单次 SDK/CLI 的结束契约是物理轮次 API,而不是目标操作 API。 ## 后果 - TUI 暴露由可移除插件提供的 Codex 形态 `/goal` 命令。 -- 人类状态会区分持久阶段与实时激活态,并报告准确的目标回合上限。 +- 人类状态会区分持久阶段与实时激活态,并报告准确的目标 Round 上限。 - 直接暂停、恢复、清除、创建与编辑不消耗模型轮次,而其已接受变更仍可从会话日志重建。 - 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。 - 无头组合保持单轮行为,除非明确选择加入目标并定义自己的长时间运行结束契约。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- 可移植命令契约没有模态编辑器或确认交互;在出现通用跨表面交互原语之前,行内编辑与明确清除是有意选择。 -- `/goal` 不接受逐命令回合上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 +- 可移植命令契约没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 +- `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 - TUI 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 - ACP 自动化服务器、无头 CLI 与 JSON-RPC 前端不消费命令注册表。 - 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离契约的独立策略层。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 1f787065ab..debd2094e0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md -2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e -2026-07-19-model-facing-goal-tools.zh.md: cc23a76e5faac2c203052d834ca0a87ca5dbed2a +2026-07-19-model-facing-goal-tools.md: 0271194a38503711de77290c915010c26a9de74b +2026-07-19-model-facing-goal-tools.zh.md: 235c3c5369871a8cdf731eee822abc4a3e6b58d1 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 18235c4841..0271194a38 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -28,7 +28,7 @@ An autonomous goal round that successfully reports completion or blocking defers Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: every `Agent.send()` or `steer()` input requires an explicit source, so the host labels direct human content `{ kind: 'user' }` and non-human producers label their own provenance. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: every `Agent.followup()` or `steer()` input requires an explicit source, so the host labels direct human content `{ kind: 'user' }` and non-human producers label their own provenance. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index cc23a76e5f..235c3c5369 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -8,37 +8,37 @@ Status: implemented 持久目标领域有意把生命周期动词提供给插件,而不直接提供给模型。模型仍然需要一个小型控制面,用于发现当前目标、根据人类意图创建目标并改变其生命周期。仅靠提示词指导无法确定是谁授权了一次变更:子智能体、注入的插件消息、陈旧的模型轮次或恢复后的会话都可能产生相同的工具参数。 -该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主目标回合必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 +该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主 Goal Round 必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 ## 决策 -位于 `packages/goal/tool-goal/` 的 `@deepseek-ai/dsh-tool-goal` 在 `ctx.goals` 之上贡献三个独占工具和一个系统提示词策略段:`get_goal`、`create_goal` 与 `update_goal`。工具名称和读取—创建—更新形态遵循 Codex 的紧凑目标工具表面,而权限规则使用本仓库公共的 agent(智能体)、会话、工具与目标接缝。 +位于 `packages/goal/tool-goal/` 的 `@deepseek-ai/dsh-tool-goal` 在 `ctx.goals` 之上贡献三个独占工具和一个系统提示词策略段:`get_goal`、`create_goal` 与 `update_goal`。工具名称和读取—创建—更新形态遵循 Codex 的紧凑目标工具表面,而权限规则使用本仓库公共的 agent(智能体)、会话、工具与目标 seam。 ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主 Goal Round 成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.send()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.followup()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 -完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 +完成与阻塞既接受直接人类权限,也接受准确的当前 Goal Round。Goal Round 权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和 Round 都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 ### 阻塞阈值 -`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主 Goal Round 调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的 Round 并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些 Round 是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确 Goal Round 的完成、仅自主 Round 触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 @@ -55,14 +55,14 @@ Status: implemented - 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 - 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 - 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 -- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- Goal Round 可以完成或报告重复阻塞,但不能自行扩大任务权限。 - 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 - 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 -- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 -- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 +- 这些工具会改变目标状态,但不调度 Goal Round、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则 Goal Round 权限路径处于休眠状态;本工具包本身不会制造这种权限。 - 面向人类的斜杠命令发现与渲染由独立的 [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) 插件负责。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 2859ffb99b..14c92dfce8 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: 00600b2c49646ebd3b692154ef945eb79a33b032 -2026-07-19-persisted-same-session-goal-domain.zh.md: 6a554438d0d70a5b4ccbf7b6ee77853af9c9ce69 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +2026-07-19-persisted-same-session-goal-domain.md: ce93a652f3910912fbf6afc28061de4d13eb2de2 +2026-07-19-persisted-same-session-goal-domain.zh.md: 7fb8dc3a6c76b91e0abf495782d51b30d3cd5b53 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 00600b2c49..ce93a652f3 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -18,11 +18,11 @@ The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked ### Durable record and replay -Every non-clear mutation uses `Agent.inject()` to append a model-visible `context/message` containing a versioned full snapshot; the session projects that content verbatim. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. This descriptive delimiter follows the repository's existing `` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. +Every mutation appends a versioned `goal/change` session event containing a full snapshot or, for clear, a revisioned tombstone. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. The [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md) owns the separation from inbox state and model context. -The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. +The replay fold derives lifecycle mutations only from `goal/change` and validates JSON shape, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds advance only from positive sequential admitted `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. -When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; reentrant append observers project each mutation exactly once. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. +Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. ### Lifecycle and live activation @@ -32,16 +32,16 @@ A cache built from any seed starts disarmed, and every `agent/session-start` edg ### Service boundary -The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. +The service accepts only the exact live `Agent` object registered under its id. A committed mutation emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, durable event folding, inbox independence, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the goal record and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered - **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions. -- **Use hidden log-only events** — rejected because durable state that changes future model behavior must be model-visible and reconstructable under the repository's logging invariant. +- **Couple each durable mutation to queued model context** — rejected by the later [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md): goal tools and scheduled continuation prompts expose state when needed, while domain persistence remains independent from queue outcomes. - **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources. - **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget. - **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation. @@ -50,7 +50,7 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data. - Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. -- Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them. +- Full snapshots simplify inspection, strict replay, and last-wins projection without adding mutation-only messages to model history. - Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. - Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work. diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 6a554438d0..7fb8dc3a6c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 持久的同会话目标领域 +# Agent Note: 持久化的同会话目标领域 Status: implemented @@ -8,55 +8,55 @@ Status: implemented 长时间运行的目标会跨越单个提示词、轮次或模型请求。若把该目标视为内存中的循环变量,进程重启时就会丢失;若只存放在 UI 状态中,又无法重建模型行为。若把会话中的每个轮次都视为目标进度,与自动工作无关的人类消息也会消耗预算。 -持久生命周期与继续执行的权限是两个不同事实。会话在重启或 fork(派生)后可以保留活跃目标,但用户打开会话时静默启动工作并不符合直觉。该领域需要可回放的状态,却不能持久化自动执行权限;它还必须作为公共 agent(智能体)与会话接缝上的插件存在,而不是具体循环中的特例。 +持久化的生命周期与继续执行的权限是两个不同事实。会话在重启或 fork(派生)后可以保留活跃目标,但用户打开会话时静默启动工作并不符合直觉。该领域需要可回放的状态,却不能持久化自动执行权限;它还必须作为公共 agent(智能体)与会话 seam 上的插件存在,而不是具体循环中的特例。 ## 决策 -位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。 +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含带品牌的 id、目标描述、持久化阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。 -持久阶段包括 `active`、`paused`、`blocked` 和 `complete`。阻塞快照包含由策略提供的 kebab-case 小写代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。 +持久阶段包括 `active`、`paused`、`blocked` 和 `complete`。阻塞快照包含由策略定义的全小写 kebab-case 代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。 ### 持久记录与回放 -每次非清除变更都通过 `Agent.inject()` 追加一条模型可见的 `context/message`,其中包含带版本的完整快照;会话会将其内容原样投射给模型。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。这个描述性分隔符沿用了仓库已有的 `` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 +每次变更都会追加带版本的 `goal/change` 会话事件,其中包含完整快照;clear 则包含带修订号的墓碑。会话日志是唯一的持久真源,因此持久化和 fork 会继承 goal 记录,而无需另设数据库或头字段。[Goal 自有持久事件决策](../architecture/2026-07-31-goal-owned-durable-events.md)负责 goal 状态与 inbox 状态、模型上下文之间的职责分离。 -回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 +回放折叠只从 `goal/change` 派生生命周期变更,并校验 JSON 形状、新 id、修订连续性、生命周期转换、计数器以及单个 goal 内单调递增的时间戳。只有当前活跃修订上带正数且连续编号、已准入的 `user/message` 来源才会推进 Goal Round,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 -当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 +增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 ### 生命周期与实时激活态 最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费者。 -从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,恢复、fork 和继续执行驱动器替换都会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,会话恢复、fork 和继续执行驱动器替换都会保留持久化目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 ### 服务边界 -服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。成功注入变更后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 +服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。变更提交后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、持久事件折叠、inbox 独立性、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性,以及连续 Goal Round 归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费方,再从外部读取持久 JSONL,以验证 goal 记录以及不存在未经请求的 Goal Round。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 - **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。 -- **使用模型不可见的纯日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须满足仓库日志不变量,保持模型可见且可重建。 +- **把每次持久变更与排队的模型上下文绑定。** 后续的 [Goal 自有持久事件决策](../architecture/2026-07-31-goal-owned-durable-events.md)不采用这一方案:goal 工具与已调度的继续执行提示词会在需要时暴露状态,而领域持久化不依赖队列结果。 - **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。 -- **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 +- **把所有会话轮次都计为 Goal Round**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 - **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。 ## 后果 - 目标历史作为普通会话数据,在持久化、恢复、无关节点压缩和会话 fork 后继续保留。 - 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 -- 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。 +- 完整快照便于检查、严格回放与 last-wins 投影,且不会向模型历史添加只用于变更的消息。 - 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 - 回合上限只约束继续执行次数;当回合、token、费用、时间或提供方限制停止工作时,策略消费者会把它们映射为不同的阻塞原因。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 +- 本领域记录状态,但不调度 Goal Round、不取消活跃轮次,也不分类异常停止。 - 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 - 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 - 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 8b2ddb5ac1..c3bbbe1d22 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-plugin-command-registration.md: 343cb5d946dba9fb881adf12c197961dfd6a359b -2026-07-19-plugin-command-registration.zh.md: 27757f05afe04d7cbd4ceaf9380b6f73441cc4b9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +2026-07-19-plugin-command-registration.md: 5233ce511dc9798733513ccbf6824f3e1b68d2e6 +2026-07-19-plugin-command-registration.zh.md: 7f41b9d2d00373901b51e3338fa51e1cadf886a9 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 343cb5d946..5233ce511d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -36,7 +36,7 @@ Expected handler failures return `CommandResult.error`. Thrown or malformed resu ### TUI mapping -The TUI registers its built-in slash commands as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. +The TUI registers its built-in slash commands as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.steer()`. Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 27757f05af..7f41b9d2d0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 插件拥有的人类命令注册 +# Agent Note: 插件自有的人类命令注册 Status: implemented @@ -8,37 +8,37 @@ Status: implemented TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派和取消都留在适配器内部,每个新命令都需要修改 TUI,可选插件也无法贡献命令。把斜杠输入当作普通模型提示同样不安全:用户可见的直接操作可能意外消耗 token,或让模型重新解释未知命令。 -共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确的逐智能体可见性、可安全 HMR 移除、直接结果渲染和请求作用域取消,同时不会自动把命令文本或输出加入模型历史。 +共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确到每个 agent(智能体)的可见性、可安全 HMR 移除、直接结果渲染和请求作用域取消,同时不会自动把命令文本或输出加入模型历史。 ## 决策 -位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的前端旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 +位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的入口旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 ### 注册表契约 -`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费方都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把消费方身份编码进共享领域。 +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验元数据并复制一份与调用方脱离的副本、冻结最终生效的定义,并返回对应 Cordis effect 的精确 disposer(资源释放器)。同一层中的重复名称会失败。每个消费方都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把消费方身份编码进共享领域。 -`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。 +`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器负责生成直接展示的错误文本。 -`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 +`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行负责后续所有语法决策。 ### 作用域与生命周期 -无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 +无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义只会为对应的同一个 agent 遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 ### 直接分派与取消 -命令在仅面向人类的命令平面中运行。注册表不会把输入转成 `user/message`,输出不会成为会话事件,两者都不会隐式发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`;生产者可以通过该智能体显式调度单独的模型可见工作,随后由生产者负责其日志记录和生命周期契约。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 +命令在仅面向人类的命令平面中运行。注册表不会把输入转成 `user/message`,输出不会成为会话事件,两者都不会隐式发送给模型。处理器接收对应的同一个目标 agent、原始输入和本次请求持有的 `AbortSignal`;生产者可以通过该智能体显式调度单独的模型可见工作,随后由生产者负责其日志记录和生命周期契约。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 ### TUI 映射 -TUI 把内置斜杠命令注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 +TUI 把内置斜杠命令注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.steer()`。 -每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 +每次提交命令都会创建一个专属 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者的 fiber(纤程)完全停稳后再完成清理。 ## 测试 @@ -63,7 +63,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - 未知斜杠输入与命令输出是确定性 UI 行为,直接模型 token 成本为零。 - 直接命令取消与模型轮次取消彼此隔离。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - 输入元数据仅限非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续注册表或消费方扩展。 - 通用命令输出仅实时存在,TUI 重启后不会重建。 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index b354999813..2f06720c93 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-same-session-goal-round-driver.md: 0e6be9fe3109336d47867ab52c585dc267309fb4 -2026-07-19-same-session-goal-round-driver.zh.md: cfd9d1aa8cbc3c17cd046df4f57a8f79a6877f5c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +2026-07-19-same-session-goal-round-driver.md: a989b9d3487ebbf8ff11a4e34cb08029f1e4ace4 +2026-07-19-same-session-goal-round-driver.zh.md: b49a759c8f3577db30b8ab45449bb7faed85a7da diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index 0e6be9fe31..a989b9d348 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -8,7 +8,7 @@ English | [中文](2026-07-19-same-session-goal-round-driver.zh.md) The goal domain can retain an objective and the model-facing tools can mutate its lifecycle, but neither should decide when another model turn begins. A continuation driver must bridge active goal state to the ordinary agent loop without adding goal-specific branches to `dsh-agent-loop`, inventing a second conversation, or treating every human turn as an autonomous iteration. -That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.send()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. +That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.followup()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. ## Decision @@ -20,15 +20,15 @@ The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `d ### Reservation and admission -When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.send()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. +When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.followup()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. -The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. +The `agent/pre-step` waterfall is the entry fence. A positive goal source enters only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream listeners return. This second check prevents an async listener from editing or pausing the goal while still entering the old prompt. -Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation is discarded before a turn opens; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. +Only the resulting `user/message` is an entered round and advances the goal fold. A stale reservation closes a blocked no-step turn; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. ### Human work and revision races -`agent/queued` distinguishes the driver's complete accepted record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed batch admits the human prompt but rejects the automatic one. Ordinary work arriving after the goal round was admitted remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. +The reserved `MessageId` distinguishes the driver's complete record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed claimed batch rejects the automatic proposal. Ordinary work arriving after the goal round entered remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. A goal mutation during a round advances its durable revision. Settlement of the older revision cannot overwrite that mutation. The driver discards the old attempt outcome, reads the new projection, and continues only if the new revision is still active and armed. This makes model-recorded completion, pause, block, and edit authoritative over the physical turn's later close reason. @@ -53,9 +53,9 @@ No abnormal outcome requests an automatic retry. A later human prompt can ask to Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver finds that exact closed turn even when a concurrent one-shot injection appended a later turn, associates the failure with the exact attempt, and disarms before the next idle decision. -Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation before the loop destroys the queued-work evidence. When that reservation is a queued or admitted goal attempt, cancellation durably pauses the goal; when cancellation belongs to unrelated human work with no goal attempt, it only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart. +Broad cancellation clears pending inbox work and aborts the active loop phase. The goal driver follows the reserved message through inbox claim/discard events and the durable aborted turn ending. Because a turn now opens before its initial claim, cancellation can close a claimed no-step attempt; the driver marks that attempt cancelled and lets the following idle edge pause the goal, just as it does for an admitted attempt. Cancellation with no matching goal attempt only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart. -This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. +`Agent.cancel()` remains the only public broad cancellation verb. Custom `Agent` implementations that claim the interface must honor the inbox, turn-ending, status, and quiescence ordering if consumers depend on it. ### Process lifecycle diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index cfd9d1aa8c..b49a759c8f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 同会话目标回合驱动器 +# Agent Note: 同会话 Goal Round 驱动器 Status: implemented @@ -6,29 +6,29 @@ Status: implemented ## 问题 -目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态连接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 +目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态桥接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 -这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.send()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 +这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.followup()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 ## 决策 -位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个完全相同的实时 `Agent`,它维护进程内调度状态,并且最多保留一个自动回合预留。 +位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个对应的同一个实时 `Agent` 对象,它维护进程内调度状态,并且最多保留一个自动回合预留。 -层次关系为目标(Goal)→ 目标回合(Goal Round)→ 轮次(Turn)→ 步骤(Step)。目标回合是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是目标回合,也绝不会增加 `roundsStarted`。 +层次关系为目标(Goal)→ Goal Round → 轮次(Turn)→ 步骤(Step)。Goal Round 是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是 Goal Round,也绝不会增加 `roundsStarted`。 该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化;“相同阻塞条件”的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值,一个策略就会出现多个所有者。 ### 预留与接纳 -当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.send()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 +当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.followup()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 -`agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 +`agent/pre-step` 瀑布是进入栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会进入步骤。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步监听器编辑或暂停目标后,旧提示词仍进入步骤。 -只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 +只有最终产生的 `user/message` 才是进入步骤的目标回合,并推进目标折叠。陈旧预留会关闭一个 blocked 的无步骤轮次;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 ### 人类工作与修订竞争 -`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 +预留的 `MessageId` 会区分驱动器自己的完整记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合的已领取批次会 reject 自动提案。目标回合已经进入步骤后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 @@ -38,45 +38,45 @@ Status: implemented | 轮次结果 | 动作 | |---|---| -| 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | -| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 | +| 持久化的 `completed` | 目标仍 active/armed 且未到上限时继续 | +| 取消已预留/接纳的 Goal Round,或该 Round 产生 `aborted` 结果 | 暂停并解除激活 | | 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 | | 其他 `error` | 以 `turn-error` 代码阻塞 | | `max-tokens` | 以 `max-tokens` 代码阻塞 | -| 持久检查点失败 | 解除激活,但不改变持久阶段 | +| 持久化检查点失败 | 解除激活,但不改变持久化阶段 | | `disposed` 或 `interrupted` | 解除激活 | | 插件新增的未知结果 | 阻塞并等待检查 | 异常结果都不会请求自动重试。之后的人类提示词可以用任何语言要求继续;模型读取已停止目标并调用目标工具的 resume 动作,记录新修订并重新激活继续执行。 -### 持久性与取消接缝 +### 持久性与取消 seam -每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到该精确的已关闭轮次,把失败关联到精确尝试,并在下一次空闲决策前解除激活。 +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到对应的同一个已关闭轮次,把失败关联到对应的同一次尝试,并在下一次空闲决策前解除激活。 -广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿在循环销毁排队工作证据前清除预留。若该预留是排队中或已接纳的目标尝试,取消会持久暂停目标;若取消属于没有目标尝试的无关人类工作,则只移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。 +广义取消会清除待处理 inbox 工作,并中止活跃 loop 阶段。目标驱动器通过 inbox 的领取/丢弃事件和持久的 aborted 轮次结束来跟踪预留消息。由于轮次现在会在首次领取前打开,取消可以关闭已领取的无步骤尝试;驱动器会把该尝试标记为已取消,并让随后的 idle 边沿暂停目标,与已准入尝试的处理方式相同。没有匹配目标尝试的取消只会移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。 -该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 +`Agent.cancel()` 仍是唯一的公共广义取消动词。若消费方依赖其顺序,实现该接口的自定义 `Agent` 必须遵守 inbox、轮次结束、status 与完全停稳的顺序。 ### 进程生命周期 `GoalService.disarm(agent)` 只移除进程内激活态。它不写会话事件、不改变修订号,也不发出目标变更。驱动器在加载到已有 agent、持久性存在不确定性以及卸载前调用该方法;之后的 `resume` 才是模型可见的持久激活边沿。 -驱动器的事件监听器和静止关闭嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect;若监听器和清理分别注册,异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试,并等待 agent 与驱动器都达到静止;之后才注销监听器。 +驱动器的事件监听器和完全停稳后关闭的流程嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect;若监听器和清理分别注册,异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试,并等待 agent 与驱动器都完全停稳;之后才注销监听器。 -紧邻插件开始卸载前,收件箱接纳可能赢得微任务竞争。在这种情况下,轮次甚至首个请求都可能已经开始,且该回合仍会持久计费;卸载一旦开始,取消就会中止它,不会再调度后续回合,目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生,就会破坏回放计数。 +紧邻插件开始卸载前,收件箱接纳可能赢得微任务竞争。在这种情况下,轮次甚至首个请求都可能已经开始,且该回合仍会持久化计入额度;卸载一旦开始,取消就会中止它,不会再调度后续回合,目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生,就会破坏回放计数。 ## 测试 单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败(包括后续一次性注入)、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 -无密钥 ACP 快照通过 `cordis.yml` 挂载已发布的自动化应用,以及真实目标领域、目标工具、目标驱动器、agent loop、持久化和回放适配器。一个源自人类的轮次创建并检查一个两回合目标;第一个自动轮次正常停止,ACP 随后取消刻意停滞的第二个回合并记录持久暂停。规范化线协议和外部 JSONL 断言证明只有一个会话、回合来源依次为 `1, 2`、生命周期变更与回放计数精确,并且没有把 `echo-agent` 当作应用替身。 +无密钥 ACP 快照通过 `cordis.yml` 挂载已发布的自动化应用,以及真实目标领域、目标工具、目标驱动器、agent loop、持久化和回放适配器。一个源自人类的轮次创建并检查一个两回合目标;第一个自动轮次正常停止,ACP 随后取消刻意停滞的第二个回合并记录持久暂停。规范化的线协议记录和外部 JSONL 断言证明只有一个会话、回合来源依次为 `1, 2`、生命周期变更与回放计数精确,并且没有把 `echo-agent` 当作应用替身。 核心取消测试固定通知顺序与隔离:只有有效取消才会通知;观察者可以在清空收件箱前排入替代工作;抛错不能阻止后续观察者;空闲调用不会发出事件。 ## 考虑过的替代方案 -- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态接缝已经足够,具体循环分支还会赋予某种策略特权。 -- **使用 `agent/turn-continuation` 把每个回合变成另一个步骤**——不予采纳,因为目标回合是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、回合计数和失败结算。 +- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态 seam 已经足够,具体循环分支还会赋予某种策略特权。 +- **使用 `agent/turn-continuation` 把每个 Round 变成另一个步骤**——不予采纳,因为 Goal Round 是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、Round 计数和失败结算。 - **持久化待处理预留**——不予采纳,因为崩溃无法证明进程内队列已经达到接纳点;只有持久 `user/message` 才消耗回合。 - **自动重试提供方或持久化错误**——不予采纳,因为重试会消耗资源,需要显式授权;停止阶段加之后的人类恢复更简单,也可观察。 - **每回合 fork 对话历史或生成新 agent**——本包不采用,因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与 workflow 原语的独立工作流插件。 @@ -90,7 +90,7 @@ Status: implemented - 恢复和 fork 在语义上的人类意图促使模型记录 resume 变更之前始终保持惰性。 - 保守的失败映射可能要求在暂时性错误后手动继续,但绝不会隐藏自动重试。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - 完成证据和阻塞条件的语义等价性仍由模型判断。独立评估器、完成证书或由验证器驱动的停止策略延期到独立策略插件。 - 本包不提供 Ralph 风格的新 agent 尝试、上下文重置、跨回合评估反馈或工作流级并行;它们属于独立的 Ralph 工作流工具。 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 39689747e1..0af24062f6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.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/feature/2026-07-20-code-mode-typed-tool-returns.md 2026-07-20-code-mode-typed-tool-returns.md: 2081d8161f0ee14493a09762b18ec7d9d07ea3c4 -2026-07-20-code-mode-typed-tool-returns.zh.md: 0fa5eec7a96ebba6796dda6221b83e91f14ebf7d +2026-07-20-code-mode-typed-tool-returns.zh.md: 49d7a73f7f37d562fcbec2923d98ffa5ced97f68 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index 0fa5eec7a9..49d7a73f7f 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Code Mode 的类型化工具返回值 +# Agent Note: Code Mode 的类型化工具返回值 Status: implemented @@ -6,17 +6,17 @@ Status: implemented ## 问题 -Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 接口,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 task id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 +Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 表层,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 task id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 -运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查格式化后的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。 +运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查后生成的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。 [规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。Code Mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 ## 决策 -Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则以真正的 `ToolCallError` reject。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线。 +Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线。 -本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败契约。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义;Native 渲染与策略投影仍由规范输出 Agent Note 定义。 +本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败契约。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 ### 生成的 SDK @@ -45,21 +45,21 @@ declare const tools: { } ``` -`jsonSchemaToTs()` 覆盖统一 schema 支持的所有节点:对象、数组、字符串、数字、整数、布尔值、null、无约束 JSON、标量 `enum` 与 `const`,以及 `oneOf`。提示词生成期间,不支持的原始结构会降级为 `unknown`,而不会导致组装失败。工具名会保留精确键名,包括必须使用引号访问的名称。 +`jsonSchemaToTs()` 覆盖统一 schema 支持的所有节点:对象、数组、字符串、数字、整数、布尔值、null、无约束 JSON、标量 `enum` 与 `const`,以及 `oneOf`。提示词生成期间,不支持的原始结构会回退为 `unknown`,而不会导致组装失败。工具名会保留精确键名,包括必须使用引号访问的名称。 ### 绑定值与失败 -分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。 +分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。 -Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其 reject 异常能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把契约承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。 +Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把契约承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。 -绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型的原生函数源码内建方法,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 +绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型、可获取原生函数源码的内建函数,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 ### 外层结果与输出账本 运行时接受以任意 JSON 类型为根的精确无损完成值。返回 `undefined` 表示省略完成值;返回 `null` 则是显式结果。`run_code` 暴露规范外层值 `{ logs: string[], result?: JsonValue }`。其 Native 渲染器先输出日志;字符串结果保持原文,其他所有 JSON 根值则使用迭代式美化渲染器。总缩进长度上限为 10 个字符,更深的子树保持紧凑格式,既保留既有的浅层文本,又确保遍历不受调用栈深度限制,且格式化输出大小与规范 JSON 大小呈线性关系。 -`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会将已捕获日志序列化为 JSON 字符串后的精确字节数计入账本,并在发送终态消息前,根据组合账本的剩余额度预检分离后的完成值或程序异常。因此,即使抛出的字符串或堆栈极大,通过 worker 端口的也只会是固定的 `output-limit` 诊断。宿主侧会针对伪造流量以及 worker 无法观察的原生管道写入,重复执行这套不可信对端计账。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值、诊断或包含日志的组合结果超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。 +`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会将已捕获日志序列化为 JSON 字符串后的精确字节数计入账本,并在发送终态消息前,根据组合账本的剩余额度预检分离后的完成值或程序异常。因此,即使抛出的字符串或堆栈极大,通过 worker 端口的也只会是固定的 `output-limit` 诊断。宿主侧会针对伪造流量以及 worker 无法观察的原生管道写入,重复执行这套面向不可信对端的账本校验。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值、诊断或包含日志的组合结果超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。 日志会在产生时立即流出,因此运行被终止时仍可保留已经纳入额度的输出。绕过 worker 中已改写流写入入口的原生 stdout 和 stderr 写入会经由彼此独立的管道传输,因此运行时在终态结算期间仍会继续在上限内捕获输出,直至 worker 完全终止,然后才组装结果。超过上限后,运行时会返回一个显式的有界失败,并携带可容纳的已捕获前缀。该外层结果随后通过普通的 `run_code` 渲染与输出落盘策略;策略可以保存已捕获的文本,并暴露其配置指定的头尾预览。输出落盘层无法恢复运行时在硬上限之外拒绝的字节。 @@ -67,7 +67,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ### 类型化句柄与生命周期 -后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 +后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;任务取消则由 `task_kill`、所有者的 dispose(资源释放)或服务 teardown(拆卸)流程负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 临时 Cordis Plugin 遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 @@ -79,11 +79,11 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ## 测试 -编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 -## 备选方案 +## 考虑过的替代方案 **返回 Native 文本并附加可选 JSON:**不予采纳。程序会面对两套相互竞争的成功契约;可选值不存在时,仍需使用工具专属的解析规则。规范值才是 API;Native 内容只是它的展示。 @@ -93,13 +93,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper **静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层输出落盘机制。 -## 影响 +## 后果 Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损校验,但不会降低中间值的开销,也不会使其具备持久性。外层输出溢出会显式导致运行失败,错误处理则有意由人类引导,而不是依赖带版本的错误代码联合。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 - 即使工具输出可以采用任意 JSON 根,subagent 和工作流中由调用方定义的结构化输出仍通过消费方级别的门禁保持对象根限制。 - Post-execute 分别提供值投影与展示投影;替换内容不是保密机制,因此策略若需向程序化调用方隐藏内容,就必须阻止调用或替换值。 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 1b9aa60021..e852c240f7 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: 61ea30cabb2abf4d4a9b4391891b5987affb91d0 -2026-07-21-cross-session-references.zh.md: 6fba44103942d29428fd820591815743dfb5d96d +2026-07-21-cross-session-references.md: b2d428e0937fdd881720720401948a1c3e7ef1f6 +2026-07-21-cross-session-references.zh.md: a59657bfcd46393a5cd1c5a5076a6eddd5d065ff diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index 61ea30cabb..b2d428e093 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -18,17 +18,17 @@ The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `send()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. -One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message` or `steering/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -TUI owns the snapshot/direct-message transaction without extending the generic inbox record. Outside the next-step acceptance window, it installs a one-shot outer `agent/prompt-submit` listener before `followup()`; an allowed decision receives the snapshot as `additionalContexts`, while a blocked or discarded prompt releases the listener and writes neither message. During prompt admission or an open turn, TUI calls `inject(snapshot)` then `steer(prompt)`, and AgentLoop stages both for the same safe boundary. If admission fails before that boundary, both remain staged for retry or a later admitted prompt; cancellation or disposal may discard them. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +TUI owns the snapshot/direct-message transaction without extending the generic inbox record. While the agent is idle, it installs a one-shot outer `agent/pre-step` listener before `followup()`; an enter decision receives the snapshot as another message, while rejection or an earlier ordinary discard releases the listener and writes neither message. While the agent is running, TUI calls `inject(snapshot)` then `steer(prompt)`, placing both in the next-step inbox for the same later claim. A rejecting or failed pre-step leaves that claimed pair removed; messages inserted after the claim remain pending. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. -Reference preparation is not a new steering protocol and does not create a turn by itself. A `followup()` outside the next-step acceptance window dispatches prompt admission; steering inside the window bypasses it while retaining snapshot order through the shared outbox. +Reference preparation is not a new steering protocol and does not create a turn by itself. Idle delivery uses `followup()` and pre-step entry; running delivery uses the shared next-step inbox while retaining snapshot order. ## Host adapters @@ -43,10 +43,10 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Alternatives considered - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. -- **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. +- **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step outbox preserve the required pairing without enlarging every message. -- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. +- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. - **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 6fba441039..a59657bfcd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -14,21 +14,21 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 -该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。 +该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是依赖项:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 -投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 +投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 或 `steering/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -TUI 负责快照/直接消息事务,不扩展通用收件箱记录。在 next-step 接受窗口之外,它会在调用 `followup()` 前安装一次性的外层 `agent/prompt-submit` 监听器;获准决策会把快照作为 `additionalContexts` 接收,而被阻止或丢弃的提示词会释放监听器,并且不写入任何消息。提示词准入期间或轮次打开时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,AgentLoop 则将两者暂存到同一个安全边界。如果准入在抵达该边界前失败,两者都会保留暂存状态,供重试或后续获准提示词使用;取消或资源释放可能丢弃它们。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +TUI 负责快照/直接消息事务,不扩展通用收件箱记录。agent 空闲时,它会在调用 `followup()` 前安装一次性的外层 `agent/pre-step` 监听器;enter 决策会把快照作为另一条消息接收,而 reject 或更早的普通丢弃会释放监听器,并且不写入任何消息。agent 运行时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,把两者放入 next-step inbox,等待后续同一次领取。pre-step reject 或失败会让这对已领取消息保持删除;领取后插入的消息继续等待。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。在 next-step 接受窗口之外调用 `followup()` 会分派提示词准入;窗口内的 steering 会绕过它,同时通过共享 outbox 保持快照顺序。 +引用准备过程不是新的 steering 协议,本身也不会创建轮次。空闲交付使用 `followup()` 和 pre-step 进入决策;运行期间的交付使用共享 next-step inbox,并保持快照顺序。 ## 宿主适配器 @@ -38,22 +38,22 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询 ## 预算与保留策略 -最多三个引用中的每一个默认独立限制在 65,536 个 UTF-8 字节以内,不设置完整提示词的总预算。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若某个源的固定序列化字段无法装入其上限,整个准备过程会失败,不会输出部分上下文。 +最多三个引用中的每一个默认独立限制在 65,536 个 UTF-8 字节以内。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若某个源的固定序列化字段无法装入其上限,整个准备过程会失败,不会输出部分上下文。 ## 考虑过的替代方案 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 -- **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 +- **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step outbox 可以保持所需配对,而无需扩大每条消息。 -- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 +- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 - **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、能力缺失和精简的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.i18n.yaml index 6d66a58813..75324f8396 100644 --- a/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.md 2026-07-21-follow-instruction-symlinks.md: 49b02c38fb49241f5941dc3431c43f031fb7193e -2026-07-21-follow-instruction-symlinks.zh.md: ba47325dde30cea899b2e038221f841bdfa2f1c6 +2026-07-21-follow-instruction-symlinks.zh.md: 3b49eaa80f84c93b59e0825b76e7f636a2251956 diff --git a/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md b/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md index ba47325dde..3b49eaa80f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md @@ -12,11 +12,11 @@ Status: implemented 指令发现不再用 `lstat` 检查末段。每个候选(用户全局的 `$DSH_HOME/AGENTS.md`、每个基础候选,以及每个本地覆盖候选)都会被解析,并对其解析后的目标做 stat,基线组合时与每一轮 `tools/post-execute` 协调时一视同仁。一个目标为常规文件的符号链接会加载该目标的内容;一个解析后的非文件目标(包括指向目录的链接)是被确认的缺失,会像缺失文件一样移除该 scope;一个 `resolve` 或 `stat` 异常被归类为暂时不可用,且从不移除已加载的 scope。`nodeStatFile` 调用 `stat`(宿主路径),`fsStatFile` 先 `resolve` 再 `stat`(提供方路径);两者都不调用 `lstat`。 -一个被跟随的符号链接对下游每一步都是普通文件。它参与按目录的内容去重([加载全部并去重 note](2026-07-21-instruction-load-all-dedup.md)),因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 现在会解析到相同内容,并像任何逐字节相同的真实副本一样被合并,而不再作为特例被跳过。 +一个被跟随的符号链接对下游每一步都是普通文件。它参与按目录的内容去重([加载全部并去重说明](2026-07-21-instruction-load-all-dedup.md)),因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 现在会解析到相同内容,并像任何逐字节相同的真实副本一样被合并,而不再作为特例被跳过。 ### 信任边界与残余风险 -跟随仓库自有的链接会越过插件的信任边界:一个被克隆的、不受信任的仓库可以携带一个 `AGENTS.md`,其符号链接目标是该进程能读取的任意文件,从而把树外内容作为工作区指导暴露出来。该内容仅作为一条被 system-reminder 模式框定的、较低权限的 user 角色前缀进入;它绝不覆盖 system、developer 或用户的直接指令,并被当作数据而非权限对待。起缓解作用的边界在文件系统层,而非本插件:在部署加载不受信任的仓库时,用 `dsh-fs-policy` 门或一个操作系统沙箱([跨家族 fs 沙箱](2026-07-14-cross-family-fs-sandbox.md))约束 `ctx.fs`。这是一个明确的、由所有者接受的取舍,而非疏漏。 +跟随仓库自有的链接会越过插件的信任边界:一个被克隆的、不受信任的仓库可以携带一个 `AGENTS.md`,其符号链接目标是该进程能读取的任意文件,从而把树外内容作为工作区指导暴露出来。该内容仅作为一条被 system-reminder 模式框定、权威性较低的 user 角色前缀进入;它绝不覆盖 system、developer 或用户的直接指令,并被当作数据而非权威依据。起缓解作用的边界在文件系统层,而非本插件:在部署加载不受信任的仓库时,用 `dsh-fs-policy` 防护机制或一个操作系统沙箱([跨家族 fs 沙箱](2026-07-14-cross-family-fs-sandbox.md))约束 `ctx.fs`。这是一个明确的、由所有者接受的取舍,而非疏漏。 ## 备选方案 @@ -26,6 +26,6 @@ Status: implemented **跟随符号链接,但拒绝解析到项目根之外的目标。** 否决:这会在错误的层(路径几何而非读取权限)重新引入一条局部的信任边界,破坏合理的「`$DSH_HOME` 指向别处」场景,并重复文件系统策略门已经拥有的遏制。 -## 影响 +## 后果 -一个符号链接指向的指令文件现在会像其目标一样被加载和渲染,从而支持在多个工具与多个 home 之间共享规范指令文件,而 `CLAUDE.md → AGENTS.md` 镜像会通过内容去重而非被跳过。指令加载不再依赖 `ctx.fs.lstat`;一个解析后的非文件是被确认的缺失,只有提供方异常才是暂时不可用。信任边界从本插件移出,进入文件系统策略与沙箱层。当部署加载不受信任的仓库时,它们必须约束 `ctx.fs`。[workspace-context note](2026-06-24-workspace-context.md) 与包(package) README 承载相同的跟随行为与残余风险声明。 +一个符号链接指向的指令文件现在会像其目标一样被加载和渲染,从而支持在多个工具与多个 home 之间共享规范指令文件,而 `CLAUDE.md → AGENTS.md` 镜像会通过内容去重而非被跳过。指令加载不再依赖 `ctx.fs.lstat`;一个解析后的非文件是被确认的缺失,只有提供方异常才是暂时不可用。信任边界从本插件移出,进入文件系统策略与沙箱层。当部署加载不受信任的仓库时,它们必须约束 `ctx.fs`。[workspace-context note](2026-06-24-workspace-context.md) 与包 README 承载相同的跟随行为与残余风险声明。 diff --git a/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.i18n.yaml index a9ac0bc6c3..913e7fc0ba 100644 --- a/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.md 2026-07-21-instruction-load-all-dedup.md: 4e895b0b7f14600adeaf8742e68eab088e3d6d24 -2026-07-21-instruction-load-all-dedup.zh.md: e27c2d2ad6e6fd291dc3344aab6ff96806fe405f +2026-07-21-instruction-load-all-dedup.zh.md: ac6b202e9c3f6500309d2cf10dc9d204da7809f5 diff --git a/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.zh.md b/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.zh.md index e27c2d2ad6..ac6b202e9c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-instruction-load-all-dedup.zh.md @@ -12,7 +12,7 @@ Status: implemented 每个列表中每个存在的候选都会被加载——先基础列表,再本地列表——按配置顺序进行。在同一目录内,内容在去除首尾空白后逐字节相同的候选会合并到该顺序中最靠前的候选,并渲染被保留文件的原始字节。去重是按目录进行的,而非全局,并且在基础列表与本地列表之间对称。比较前先做去空白处理,可以容忍某文件与其近似副本之间的末尾换行或缩进差异,同时仍逐字节渲染保留下来的文件——这正是需求所要求的「格外稳妥」的比较。 -符号链接现在会统一经此流转。指令发现会解析每个候选并对其目标做 stat,而非拒绝末段的符号链接,因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 会解析到相同内容,并在此像任何逐字节相同的真实副本一样被合并。因此内容去重会通过与真实副本相同的路径把常见的符号链接镜像只渲染一次。[跟随符号链接 note](2026-07-21-follow-instruction-symlinks.md) 拥有该反转决策及其残余的信任边界风险。 +符号链接现在会统一经此流转。指令发现会解析每个候选并对其目标做 stat,而非拒绝末段的符号链接,因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 会解析到相同内容,并在此像任何逐字节相同的真实副本一样被合并。因此内容去重会通过与真实副本相同的路径把常见的符号链接镜像只渲染一次。[跟随符号链接说明](2026-07-21-follow-instruction-symlinks.md) 拥有该反转决策及其残余的信任边界风险。 ## scope 键改为按候选划分 @@ -30,8 +30,8 @@ Status: implemented **不做去空白、直接比较原始字节。** 否决:一个添加末尾换行的编辑器,或一个重排缩进的副本,都会让实质相同的文件无法去重。比较前去空白正是需求所要求的宽容键,而保留下来的文件仍渲染其原始字节。 -**跟随符号链接,从而让镜像通过内容去重。** 为本次改动否决以保留「不跟随」不变式,随后另行采纳:[跟随符号链接 note](2026-07-21-follow-instruction-symlinks.md) 反转了该不变式,此后符号链接镜像会被解析,并像真实副本一样通过内容去重。 +**跟随符号链接,从而让镜像通过内容去重。** 为本次改动否决以保留「不跟随」不变式,随后另行采纳:[跟随符号链接说明](2026-07-21-follow-instruction-symlinks.md) 反转了该不变式,此后符号链接镜像会被解析,并像真实副本一样通过内容去重。 -## 影响 +## 后果 -一个携带两个不同真实指令文件的目录现在会把两者都暴露;一个第二个文件仅仅是镜像的目录仍只渲染一次,而无处不在的符号链接场景保持不变。可见的行为差异被限定在携带两个不同真实文件的迁移期仓库中。scope 键的形态从层级哨兵改为按候选划分,`previousPath` 也从持久的变更元数据中消失;`dsh-session` 对旧会话不作兼容承诺,因此两者都是无成本的改动。版本缓存行新增了一个 `trimmedDigest` 字段,协调过程现在按目录比较去空白后的内容,因此一个未变更的文件可以被同级文件的收敛所移除——这是[状态模型](2026-06-24-workspace-context.md)此前无法产生的转换。 +一个携带两个不同真实指令文件的目录现在会把两者都暴露;一个第二个文件仅仅是镜像的目录仍只渲染一次,而无处不在的符号链接场景保持不变。可见的行为差异被限定在携带两个不同真实文件的迁移期仓库中。scope 键的形态从层级哨兵改为按候选划分,`previousPath` 也从持久化的变更元数据中消失;`dsh-session` 对旧会话不作兼容承诺,因此两者都是无成本的改动。版本缓存行新增了一个 `trimmedDigest` 字段,协调过程现在按目录比较去空白后的内容,因此一个未变更的文件可以被同级文件的收敛所移除——这是[状态模型](2026-06-24-workspace-context.md)此前无法产生的转换。 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml index 42b09bc4f6..ee932dd716 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md 2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8 -2026-07-21-local-instruction-overlay.zh.md: 0fd45cfcdaf6db1ea6cb0746c8d8cfb3e86c76d7 +2026-07-21-local-instruction-overlay.zh.md: c97ed04607f497d829da0e904c248836252c73f7 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md index 0fd45cfcda..c97ed04607 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -个人的、被 git 忽略的指导文件(`AGENTS.local.md` / `CLAUDE.local.md`)是 Claude Code 的一项约定,用于存放刻意不提交、每位开发者各自的覆盖内容。[workspace-context 插件](2026-06-24-workspace-context.md)每个目录只加载一个候选,因此只有把某个 `.local.` 名字加进 `instructionFileCandidates` 才能读到它;而由于一个目录只有一个胜出者,这样做只会让它*遮蔽*已提交的基础文件,而不是补充它。这与这些名字所暗示的「基础文件加个人覆盖层」的叠加模型正好相反,而且它默认是关闭的。 +被 git 忽略的个人指导文件(`AGENTS.local.md` / `CLAUDE.local.md`)是 Claude Code 的一项约定,用于存放刻意不提交、每位开发者各自的覆盖内容。[workspace-context 插件](2026-06-24-workspace-context.md)每个目录只加载一个候选,因此只有把某个 `.local.` 名字加进 `instructionFileCandidates` 才能读到它;而由于一个目录只有一个胜出者,这样做只会让它*遮蔽*已提交的基础文件,而不是补充它。这与这些名字所暗示的「基础文件加个人覆盖层」的叠加模型正好相反,而且它默认是关闭的。 ## 决策 @@ -26,12 +26,12 @@ Status: implemented **通过 `instructionFileCandidates` 保持按需开启。** 否决:一个目录只有一个胜出者,因此加进该列表的 `.local.` 名字会遮蔽基础文件,而非补充它。packages 指引要求把按需开启项排除在出厂默认之外,但此处强有力的现有实践、以及用户对 `.local.` 文件总会被读取的预期,压过了这一考量。 -**在产品 `cordis.yml` 层面设默认,而非在插件 schema 中。** 否决:这样只会为记得开启的那个前门启用 `.local.`,从而在 TUI/ACP/headless 之间割裂行为,并重复一个本应与既有候选默认值放在一起的取值。 +**在产品 `cordis.yml` 层面设默认,而非在插件 schema 中。** 否决:这样只会为记得开启该功能的那个产品入口启用 `.local.`,从而在 TUI/ACP/headless 之间割裂行为,并重复一个本应与既有候选默认值放在一起的取值。 **两个层级复用原始目录作为 scope 键。** 否决:同一目录下的基础文件与本地文件会在每个以 scope 为键的映射中冲突,于是对其中一个的改动会抑制或覆盖另一个。为每个候选设置各自独立的 scope 键让两者保持独立,且无需扩展持久化的元数据结构。 **将覆盖层扩展到用户全局 scope。** 暂缓:`$DSH_HOME` 是单个固定的 `AGENTS.md`,没有可供补充的已提交基础文件,因此在出现具体需求前始终只有基础文件。 -## 影响 +## 后果 -`.local.` 指导在所有产品中默认被读取,无需按部署单独配置,与邻近工具保持一致。每个项目目录可以为每个存在的候选贡献一个持久 scope 而非仅一个,因此动态发现、编辑和移除会分别独立地协调基础文件与本地文件。scope 键现在[按候选划分](2026-07-21-instruction-load-all-dedup.md);`dsh-session` 对旧会话不作兼容承诺,因此这是一次无成本的改动。用户全局 scope 仍然只有基础文件,这一点作为 Known Limitation 记录在包 README 中。 +`.local.` 指导在所有产品中默认被读取,无需按部署单独配置,与邻近工具保持一致。每个项目目录可以为每个存在的候选贡献一个持久 scope 而非仅一个,因此动态发现、编辑和移除会分别独立地协调基础文件与本地文件。scope 键现在[按候选划分](2026-07-21-instruction-load-all-dedup.md);`dsh-session` 对旧会话不作兼容承诺,因此这是一次无成本的改动。用户全局 scope 仍然只有基础文件,这一点作为已知限制记录在包 README 中。 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 029cc4e19c..36468994e1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.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/feature/2026-07-21-log-backed-session-titles.md 2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c -2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174 +2026-07-21-log-backed-session-titles.zh.md: a3a8cb6fa4b766657176f2827a3641d4874b834b diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b0c7e9d76a..a3a8cb6fa4 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -22,7 +22,7 @@ Status: implemented ### 输入与异步时序 -只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 +只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制内容的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果会追加一个独立事件,而不改变轮次状态。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方取消不会回滚已接受的回退事件,`refresh()` 会在返回成功前重新检查信号。并发刷新会在提供方工作之前预留会话本地修订号,因此较新的调用会在任何调用有机会造成提供方完成顺序倒置之前中止并取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 @@ -30,11 +30,11 @@ Status: implemented ### 注册、路由与失败策略 -`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回一个可等待完成的 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方执行资源释放时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话资源释放会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。 +`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回一个可等待完成的 effect 资源释放函数。第二个活跃注册会立即抛出错误。释放提供方注册时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话资源释放会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。 模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。发出的 `GenerateOptions` 携带 `purpose: 'session-title'`;DeepSeek 适配器将该用途映射为禁用思考且省略推理强度设置的请求,使受限输出成为可见的标题文本,而主对话仍沿用已配置的思考模式。输入字节数按最终形成的用户提示词计算,其中包括包装文本、seq 字段和 JSON 转义;系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间,因此即使拦截器或适配器忽略中止信号,超时后返回的成功结果也不会被接受。 -自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。 +自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会使调用方的调用被拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。 ### 显式重命名 @@ -61,6 +61,6 @@ Status: implemented - 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。 - Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷启动列表项会在会话附加后改用标题。 - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 -- 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 +- 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV Cache 标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 - 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index e1999f49cb..7712757384 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: 21f200f165acdf370a5896acb8f54afe33a4ee99 -2026-07-22-docked-web-goal-bar.zh.md: eb8e3edda54207b06e25812a38f969e74f342c34 +2026-07-22-docked-web-goal-bar.md: ffddef6cec8eb632cd44bb5352de246db7413c02 +2026-07-22-docked-web-goal-bar.zh.md: b732f71cfc3d3f813641c2ad9c594134beb2e440 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index 21f200f165..ffddef6cec 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -16,13 +16,13 @@ Visibility drives the label and actions: active shows "Ongoing Goal" with pause/ `GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. -The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped. +The runtime session gains the goal surface the strip (and future UI) needs through the host-computed `goal` projection. The history tail seeds its whole current value, and `session/projection` frames update it when durable `agent/inbox/spliced` insertions commit goal snapshots or clear tombstones; later context admission is irrelevant to UI freshness. The four rendered mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method. The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens. ## Testing -`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, rapid same-frame clear clicks dispatch once and a successful clear hides before projection convergence, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible and retryable in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. +`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, rapid same-frame clear clicks dispatch once and a successful clear hides before projection convergence, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible and retryable in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin folded-error results and projection updates. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. ## Alternatives considered @@ -35,6 +35,6 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc - Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. - Goal mutations are single-flight within the component; a successful clear hides its exact goal immediately while projection delivery converges, preventing duplicate CAS errors without making transient UI state authoritative. -- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). +- The runtime session exposes the goal verbs over RPC with folded transport errors and consumes the host's durable whole-goal projection on open and live updates. - Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index eb8e3edda5..b732f71cfc 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -16,13 +16,13 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T `GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 -运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 +运行时会话通过由 host 计算的 `goal` 投影获得横条(以及未来 UI)所需的 goal 表面。历史尾页会提供完整当前值作为初始状态;持久 `agent/inbox/spliced` 插入项提交 goal 快照或 clear 墓碑时,`session/projection` 帧会更新该值,后续上下文准入与 UI 新鲜度无关。4 个实际渲染的变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果。 横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。 ## 测试 -`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;同一帧内快速连续点击清除只会分发一次,清除成功后横条会在投影收敛前隐藏;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中且可重试。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 +`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;同一帧内快速连续点击清除只会分发一次,清除成功后横条会在投影收敛前隐藏;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中且可重试。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定折叠错误结果和投影更新。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 ## 考虑过的替代方案 @@ -35,6 +35,6 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T - Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 - 目标变更在组件内走 single-flight;清除成功后会在投影投递收敛期间立即隐藏与其 id 完全匹配的目标,既防止重复 CAS 错误,又不会把瞬态 UI 状态视为权威。 -- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 +- 运行时会话通过 RPC 暴露 goal 动词并折叠传输层错误,在打开时和 live 更新时消费 host 的持久完整 goal 投影。 - 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml index 10eddece3c..f5a469377b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-bind-address.md 2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503 -2026-07-22-web-bind-address.zh.md: f539fff93628205bf0099d8f23dfd13d14e55ca5 +2026-07-22-web-bind-address.zh.md: 5a8c49461b90abfd2476a56d7d148cdaaacd01e3 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md index f539fff936..5a8c49461b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -1,4 +1,4 @@ -# Agent Note:显式指定 Web 绑定地址 +# Agent Note: 显式指定 Web 绑定地址 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。 -HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包(package)边界明确表达自己的网络策略。 +HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包边界明确表达自己的网络策略。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index cd9e4f7e9f..8754e37cc7 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.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/feature/2026-07-23-session-telemetry-otel-revival.md 2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17 -2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f +2026-07-23-session-telemetry-otel-revival.zh.md: 0deb0b81ce262db4fa4ae26076cd59756d628d20 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index cc09717e34..0deb0b81ce 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -1,37 +1,37 @@ -# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend +# Agent Note: 设有强制脱敏点和 OTel 后端的会话遥测 seam Status: implemented [English](2026-07-23-session-telemetry-otel-revival.md) | 中文 -## Problem +## 问题 -每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。 +每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费方:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel 后端曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(后端契约、coordinator、handoff 游标、分片投影)本身合理且经过评审;导出侧的立场才是阻塞点。 -## Decision +## 决策 `packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: -- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 -- **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的接管、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每个(轮次、步骤)组合的首分片投影、`agent/error` 转发、以及 dispose(资源释放)时的 `shutdown` 记录。 +- **`telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 -边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),两份 README 对此如实陈述。 -## Alternatives considered +## 考虑过的替代方案 -**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 +**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、持久化 seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理流水线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 -**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经该脱敏点。 -**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来「脱敏已开启」的虚假信心,且误报会破坏未提出此要求的消费方所接收的导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 -**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费方。 -**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:接管操作现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 -**将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 +**将 seam 的轮次边界 `flush()` 提示转发到 OTel 提供方的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及提供方固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用提供方那个带超时包装的版本。 -## Consequences +## 后果 -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项即可把会话流接入任何 OTel 兼容体系;删除该配置项即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 656a52d300..febc46c94a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 -2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 +2026-07-23-web-assistant-markdown.md: c21f2dc3c0aff98dd6ea6a88ea7a4742198c92d6 +2026-07-23-web-assistant-markdown.zh.md: e6677a6a22050b59343a67d6b282b92695c24009 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d5074e6090..c21f2dc3c0 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,9 +12,9 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. +`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. -Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes. +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. @@ -36,6 +36,10 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. +**Preprocess Markdown source or repair text nodes after parsing for CJK punctuation boundaries.** A source rewrite must reproduce escape, code, math, and delimiter rules before the parser owns those distinctions, while a text-node repair has already lost some source intent and cannot compose with parsed inline nodes. Extending attention at the tokenizer boundary preserves the upstream resolver and limits the divergence to delimiter eligibility. + +**Require the model to emit standard links and leave URL-shaped inline code inert.** Output guidance cannot make persisted or third-party model replies uniform, and inline code is a common way to distinguish a literal endpoint. Recognizing only a complete absolute HTTP(S) value at the rendered inline-code boundary preserves code semantics while applying the existing untrusted-link policy. + ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. URL-shaped inline code becomes navigable without changing its visible literal, while unsafe schemes and mixed code remain non-interactive. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 31f0fd6835..e6677a6a22 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,9 +12,9 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 -视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。 +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 @@ -36,6 +36,10 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 +**为处理 CJK 标点边界而预处理 Markdown 源文本,或在解析后修复文本节点。**源文本重写必须在解析器掌握这些区别之前复现转义、代码、数学公式与定界符规则;文本节点修复则已经丢失部分源文本意图,也无法与已解析的行内节点组合。在分词器边界扩展 attention 可保留上游 resolver,并将差异限制在定界符的适用条件上。 + +**要求模型输出标准链接,并让 URL 形态的行内代码保持不可交互。**输出指引无法统一已持久化回复与第三方模型回复,而行内代码是将端点标记为字面值的常见方式。仅在行内代码的渲染边界识别完整的绝对 HTTP(S) 值,可在应用现有不受信任链接策略的同时保留代码语义。 + ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。URL 形态的行内代码会在不改变其可见字面文本的情况下变得可导航,而采用不安全 scheme 或混有其他内容的代码仍不可交互。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml index 4d686731fe..3c006bc9f1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 -2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +2026-07-23-web-permission-and-approval.md: 06e184fe32ac684e3d8b0820d915ca7c372a5760 +2026-07-23-web-permission-and-approval.zh.md: b74e93006cb95c398eb464a9bd9a59e8c9cc57be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md index cd402a039e..06e184fe32 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -14,15 +14,15 @@ The web host composes the same sandboxed product path as the acp-agent compositi `createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. -The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/pre-step`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. -Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-question, including the `plan-review` decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies only requests satisfying the plan-review composer's binary rendering constraints as plan reviews, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable `PendingWait`; tracking clears per connection generation so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. ## Alternatives considered **Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. -**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. +**A session event for pending interactions instead of the live registries.** Rejected: answerable requests are transient interaction state, not durable session data — approval's `approval/asked`/`decided` audit pair already logs its durable half. Persisting requested frames would re-ask dead questions on replay. **Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. @@ -30,4 +30,4 @@ Client-side, `Session` gained `permissions` and `setPermission`, and approval an ## Consequences -Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. +Web sessions start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-question over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md index ce4964789b..b74e93006c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -14,15 +14,15 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l `createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 -权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/pre-step` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 -在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪以连接代次为单位清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 ## 曾考虑的替代方案 **在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 -**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 +**用一个会话事件承载 pending 交互,而非实时注册表。** 不予采纳:可应答请求是瞬态的交互状态,而非持久的会话数据——审批的 `approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 **仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 @@ -30,4 +30,4 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l ## 后果 -Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 +Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-question 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index fd89fb3db5..e7e459429b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d -2026-07-23-web-todo-display.zh.md: ac773a3c587183aa9a152caff2812686bc79f3c7 +2026-07-23-web-todo-display.md: d5939c0fa6ab83a61f1932622c4b6a150ccaded7 +2026-07-23-web-todo-display.zh.md: b1ecf679efe81016e00c5c1221c04730b3ce98bb diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 7223ff9adb..d5939c0fa6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,11 +18,11 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot -The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index ac773a3c58..b1ecf679ef 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -4,33 +4,33 @@ Status: implemented [English](2026-07-23-web-todo-display.md) | 中文 -## Problem +## 问题 -`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP 桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。 +`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP(Agent Client Protocol)桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。 -## Decision +## 决策 -把 `todo/write` 当作 Session 副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。 +把 `todo/write` 当作会话副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。 ### 副作用通道,与窗口回放收敛 -`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),并在 `turn/start` 清空([按 turn 界定的计划生命周期](2026-07-28-todo-plan-clears-on-next-turn.md))。`rebuildDerivedFromWindow` 从空计划扫过窗口,仅当窗口从未判定计划(无 `todo/write` 且无 `turn/start`)时恢复尾页种子;否则以窗口内写入/`turn/start` 折叠为准。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不再播种),而 host 对尾页请求要么带上投影、要么在无站立计划时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 +`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),并在 `turn/start` 清空([按轮次界定的计划生命周期](2026-07-28-todo-plan-clears-on-next-turn.md))。`rebuildDerivedFromWindow` 从空计划扫过窗口,仅当窗口从未判定计划(无 `todo/write` 且无 `turn/start`)时恢复尾页种子;否则以窗口内写入/`turn/start` 折叠为准。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不再播种),而 host 对尾页请求要么带上投影、要么在没有当前有效的计划时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:若 host 在持久化实时写入前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅存在于日志中的 UI 状态;绝不纳入派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 -### TodoPanel:长驻列表作为一条常驻横条 +### TodoPanel:持久化列表作为一条常驻横条 -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 -专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例同一接缝、同一载序姿态(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `conversation.chat.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 -## Alternatives considered +## 考虑过的替代方案 - **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 -- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给"composer 上方常开横条"安排的家,硬编码绕开了 slot 注册表的 disposal 与定序。 +- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给「composer 上方常开横条」安排的位置,硬编码绕开了 slot 注册表的 disposal 与定序。 - **面板放进 details 列**——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。 - **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。 -## Consequences +## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按 turn 界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上的站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍站立且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加上组装后的无密钥快照(`apps/web/tests/todo-display.snapshot.ts`)在构建后的完整客户端依赖图中固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按轮次界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 7e865d0d6d..7718957ab6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md 2026-07-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c -2026-07-24-provider-retry-policies.zh.md: 788f1f1963861e1b46ff0d8798e53e01bd9892b4 +2026-07-24-provider-retry-policies.zh.md: d44f954d75a09a40988655ef486733dc2ac6f58c diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 788f1f1963..d44f954d75 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose 或替换,agent loop 仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。 +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。 ```yaml providers: @@ -34,9 +34,9 @@ providers: jitterRatio: 0.2 ``` -监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。它会根据已解析实际服务策略的所有字段生成规范键;由于错误资格按集合成员判断,生成时会对 `retryableCodes` 排序。重试历史只会对同一提供方和同一规范键延续。因此,即使模式未变,只要路由替换后的次数上限、错误代码成员或退避不同,重试计数与初始延迟都会重新开始。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;其他情况委托后续处理。 +监听器从失败步骤关闭时生效的持久化 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。它会根据已解析的实际服务策略的所有字段生成规范键;由于是否可重试是按集合成员关系判断的,生成时会对 `retryableCodes` 排序。重试历史只会对同一提供方和同一规范键延续。因此,即使模式未变,只要路由替换后的次数上限、错误代码成员或退避不同,重试计数与初始延迟都会重新开始。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;其他情况委托后续处理。 -always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。重试监听器会持有并排空已委托的恢复,轮次取消或插件 dispose(资源释放)只能在其结束后完成;随后监听器会应用取消,而不会采用迟到的下游决定。成功、轮次取消和插件 dispose 是仅有的终止路径。 +always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。重试监听器会持有并排空已委托的恢复,轮次取消或插件 dispose 只能在其结束后完成;随后监听器会执行相应的中止操作,而不会采用迟到的下游决定。成功、轮次取消和插件 dispose 是仅有的终止路径。 两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 @@ -46,11 +46,11 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 **单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 -**在 `dsh-llm-retry` 上维护单独的精确提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 +**在 `dsh-llm-retry` 上维护单独的指定提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 **设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的契约,并把任意选取的运维上限序列化成看似有意义的数值。 -**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭 step 的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 +**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭步骤的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 **把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。 @@ -60,6 +60,6 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 后果 -normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复契约。 +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复契约。 -本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭 step 恢复、单次可见适配器尝试、结构化失败与持久状态设计。 +本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭步骤恢复、单次可见适配器尝试、结构化失败与持久化状态设计。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b1e6e8b7d1..37839002ae 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5 -2026-07-24-web-session-model-selector.zh.md: ba76b87b9bd43fff97bf9ea76624f5f75b10e135 +2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0 +2026-07-24-web-session-model-selector.zh.md: bdfa2f856a799cc86600650829a2df62a7dfdc18 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 003c0b5ac1..05e923fb3b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -12,11 +12,11 @@ The Web conversation displayed and sent through the Host's fixed provider/model The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, while exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently on purpose: the TUI still renders the unlisted current model as its own row and marks it current, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so a target the user just deleted should read as a decision to make rather than a selection to keep; the TUI, which only picks from what exists, has no such edit to reconcile. The cost is real and accepted — a Web composer showing the unset label can still send to the routed target — and the divergence is deliberate, not a missed migration. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. -`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. +`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current target is absent from the groups, the trigger instead displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model replaces the complete target through the existing selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. @@ -40,4 +40,4 @@ Any Host-backed Web conversation, including a blank session, can switch among dy ## Testing -Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration, unlisted current targets, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, and effort submission. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids. +Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for a removed row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture removes the active catalog row and pins the fallback before choosing a replacement. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index ba76b87b9b..bdfa2f856a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -10,13 +10,13 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 ## 决策 -Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 +Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;精确解析则决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门有意对这一状态给出不同回答:TUI 仍把未列出的当前模型渲染为独立一行并标记为当前,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门,因此用户刚刚删除的目标应当读作一个有待作出的决定,而不是一项可以保留的选择;TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 -`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 +`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前目标不在分组中时,触发器改为显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过现有选择路径替换完整目标。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 @@ -40,4 +40,4 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm ## 测试 -Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复、当前未列出目标、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示与推理强度提交。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID。 +Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、建议性的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示、推理强度提交,以及已删除模型行的 `Select model` 回退。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID;DeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index ab957282ba..0308005515 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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/feature/2026-07-25-subagent-policy-inheritance.md 2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca -2026-07-25-subagent-policy-inheritance.zh.md: 7a09fd972f53b46655df93ddf9625455f2077460 +2026-07-25-subagent-policy-inheritance.zh.md: c26e6bf8b79c86855022c384673957fe04ff761d diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 7a09fd972f..c26e6bf8b7 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -18,14 +18,14 @@ Status: implemented ### 被拦住的子 agent 会经历什么 -受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会失败关闭,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 +受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会以拒绝方式失败,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 ## 考虑过的替代方案 - **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。未发布设置阶段的事件具备所需顺序,并复用现有持久化存储。 - **将新策略事实与构造历史合并**:不予采纳。`Session.firstLiveSeq` 会把完整的构造种子归类为回放历史,因此遥测会跳过仅属于子 agent 的事实。未发布设置让历史与新事实留在该边界各自原有的一侧,无需再增加会话选项。 - **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 -- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。 +- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会记录任何值,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 - **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index dd94eb6cd5..f44f0911f5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md 2026-07-26-code-dispatch-log-spill.md: eee8fb73b3f1ddba0a2da3ad5a9d2d4417d5951c -2026-07-26-code-dispatch-log-spill.zh.md: 664a2aefcfef198d56809c289e10827a8084a06a +2026-07-26-code-dispatch-log-spill.zh.md: 3ef95b5bc0aec1238fd9bb89f545b7f23938ea79 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index 664a2aefcf..3ef95b5bc0 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -1,4 +1,4 @@ -# Agent Note:将 Code Mode 子分发结果的持久副本纳入 spill 机制 +# Agent Note: 将 Code Mode 子分发结果的持久化副本纳入 spill 机制 Status: implemented @@ -14,7 +14,7 @@ Status: implemented **在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开 seam,调用器绝不加宽服务表面。故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 +- **seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开 seam,调用器绝不扩大服务接口。故障被兜住:监听器抛出异常时回退到未整形的内容,并用可处理任意抛出值的错误格式化,确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交通道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 - **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 - **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 @@ -28,4 +28,4 @@ Status: implemented ## 后果 -对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 Known Limitations 条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 +对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 「已知限制」条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。Web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml index c1350b3084..d2ffd775a4 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md 2026-07-26-code-dispatch-ui-foundation.md: a1629c77304bc7ef744f7a09241bcdfc81e461ae -2026-07-26-code-dispatch-ui-foundation.zh.md: 164b1e8eed343e88b6529e4fedde06ba442d7d51 +2026-07-26-code-dispatch-ui-foundation.zh.md: d49b6956f75356511ca3b0f9f2e88b42629230f6 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md index 164b1e8eed..d49b6956f7 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md @@ -1,31 +1,31 @@ -# Agent Note:Code Mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 +# Agent Note: Code Mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 Status: implemented [English](2026-07-26-code-dispatch-ui-foundation.md) | 中文 -> 范围:让 UI 能以与原生工具调用相同的保真度渲染 Code Mode 轮次的宿主侧契约变更,即 Code Mode web UI 堆叠 PR(Pull Request)链的第一个 PR。传输设计归 [Code Mode 基础](2026-06-15-code-mode.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用 seam,归本篇所有。 +> 范围:让 UI 能以与原生工具调用相同的保真度渲染 Code Mode 轮次的宿主侧契约变更,即 Code Mode Web UI 堆叠 PR(Pull Request)链的第一个 PR。传输设计归 [Code Mode 基础](2026-06-15-code-mode.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用 seam,归本篇所有。 ## 问题 -`run_code` 轮次过去在每个产品表面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:规划中的 web 对话视图会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 Code Mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 +`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:规划中的 Web 对话视图会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 Code Mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 ## 决策 三项变更,每项对应一个障碍: -1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的契约:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 +1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的契约:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(Loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的 seam:设计目标是让 web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的 seam:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 -**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR 链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。已记录副本的 spill 集成推迟到本链靠后的 PR(投影已经存在;待事件形状随 start/end 事件对一同定形,把它接入桥接层只是机械工作)。 +**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR 链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。持久化副本的 spill 集成推迟到本链靠后的 PR(投影已经存在;待事件形状随 start/end 事件对一同定形,把它接入桥接层只是机械工作)。 -**一个 `--tools-mode` CLI(命令行界面)标志或 profile 配置键。** 推迟,而非否决:标志语法暗示永久性,profile json 又是用户配置;两者都会固化这个 seam,而按会话选择的设计本就打算移除它。环境变量则如实呈现了它权宜之计的本质。 +**一个 `--tools-mode` CLI(命令行界面)标志或 profile 配置键。** 推迟,而非否决:标志语法暗示永久性,profile JSON 又是用户配置;两者都会固化这个 seam,而按会话选择的设计本就打算移除它。环境变量则如实呈现了它权宜之计的本质。 -**记录规范 `value`,而非渲染后的 `content`。** 否决:`tool/result` 持久化的是内容而非值(见[规范输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)),与原生同等保真意味着与之精确对齐;值在任何地方都保持执行期本地。 +**记录规范 `value`,而非渲染后的 `content`。** 否决:`tool/result` 持久化的是内容而非值(见[规范输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)),与原生同等保真意味着与之精确对齐;值始终仅存在于执行期本地。 ## 后果 -会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 code-mode 快照 fixture(测试前置数据)已重新录制。模型可见表面扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 code-mode 系统提示词/工具 schema 快照都发生了变化。web UI 堆叠 PR 链(后续各 PR)直接构建在新的事件载荷之上;每个子调用的实时运行状态还需要一对分发 start/end 事件,这将再次重塑本事件的形状。 +会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 Code Mode 快照 fixture(测试前置数据)已重新录制。模型可见表面扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 Code Mode 系统提示词/工具 schema 快照都发生了变化。Web UI 堆叠 PR 链(后续各 PR)直接构建在新的事件载荷之上;每个子调用的实时运行状态还需要一对分发 start/end 事件,这将再次重塑本事件的形状。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml index 41b52e29c7..9086e891db 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md 2026-07-26-code-mode-chat-subcall-rows.md: 7d666f0a9e4b8bdb9bd6f5d0d0984fee0c4b21e2 -2026-07-26-code-mode-chat-subcall-rows.zh.md: fb9b0c62bb702cfdb7ba3c8ccce73d8e43f29c1b +2026-07-26-code-mode-chat-subcall-rows.zh.md: b6b7de6c34673a0a0fa801681d642067a324d4cd diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md index fb9b0c62bb..b6b7de6c34 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md @@ -1,32 +1,32 @@ -# Agent Note:Code Mode 的 chat 渲染——子调用作为父行之下的原生行 +# Agent Note: Code Mode 的 chat 渲染——子调用作为父行之下的原生行 Status: implemented [English](2026-07-26-code-mode-chat-subcall-rows.md) | 中文 -> 范围:web chat 视图如何渲染一个 `run_code` 轮次,即 Code Mode UI 堆叠 PR(Pull Request)链的 client 侧一半,构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.md)所有。 +> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 Code Mode UI 堆叠 PR(Pull Request)链的客户端侧部分,构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.md)所有。 ## 问题 -启用 Code Mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的 details 面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 +启用 Code Mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的详情面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 ## 决策 -**子调用是 surface 流之外单独索引的 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。** +**子调用在界面流之外单独索引为 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。** -- **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。live mux 帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用 memo 稳定)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的 host 类型进不了 client 程序——host/client 两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。 -- **渲染层**:`ChatView` 的 `CallRow` 先渲染父行,随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`,每一行都经由同一个 `'conversation.chat.toolview'` keyed 孔位、以 `entryKey = sub-tool name` 分发,并共用同一个 `GenericToolCard` fallback。与原生行的同一性由构造保证:一个 keyed 注册(例如 bash 样例)接管子行与接管顶层行的方式完全相同,注册本身零改动。运行中的父调用(`runningCalls`)也以同样的方式嵌套目前已产生的分发,因此子行在运行期间实时流入(PR1 在每次分发完成时即记录该分发)。 -- **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 信封。 -- **details 面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 +- **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。实时多路复用帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用稳定,便于 memo 化)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的宿主类型无法进入客户端程序——宿主端/客户端两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。 +- **渲染层**:`ChatView` 的 `CallRow` 先渲染父行,随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`,每一行都经由同一个 `'conversation.chat.toolview'` keyed slot、以 `entryKey = sub-tool name` 分发,并共用同一个 `GenericToolCard` 后备组件。与原生行的同一性由构造保证:一个 keyed 注册(例如 bash 样例)接管子行与接管顶层行的方式完全相同,注册本身零改动。运行中的父调用(`runningCalls`)也以同样的方式嵌套目前已产生的分发,因此子行在运行期间实时流入(PR1 在每次分发完成时即记录该分发)。 +- **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。 +- **详情面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 ## 曾考虑的替代方案 -**把子调用平铺进 surface 流(折入 `nodes`)。** 否决:这会歪曲 transcript——模型只发起了一次调用;嵌套在父行之下既保住代码↔调用的关联,也让 fold 的模型可见顺序不变式原封不动。 +**把子调用平铺进 surface 流(折入 `nodes`)。** 否决:这会歪曲 transcript——模型只发起了一次调用;嵌套在父行之下既保住代码↔调用的关联,也让折叠过程的模型可见顺序不变式原封不动。 **隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 Code Mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 -**专用的子调用行组件。** 否决:本功能的全部要义就在于与原生行保持同一性;一个平行组件必然漂移。嵌套包装层(缩进 + 左侧边线)是子调用唯一的专属 chrome。 +**专用的子调用行组件。** 否决:本功能的全部要义就在于与原生行保持同一性;一个平行组件必然漂移。嵌套包装层(缩进 + 左侧边线)是子调用唯一的专属视觉装饰。 ## 后果 -自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的 opt-out,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属判断会同时检验两个层级)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实 round、无密钥回放),共同锁定整个表面;jsdom 套件则锁定 slot 分发、错误状态、details 解析与索引引用稳定性。 +自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属判断会同时检验两个层级)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 测试套件则锁定 slot 分发、错误状态、详情解析与索引引用稳定性。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml index 91685e9811..2d3d73f65d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md 2026-07-26-code-mode-live-parallel-dispatch.md: b4afc21be902d8ed3e5bee2ad1a540413a864f25 -2026-07-26-code-mode-live-parallel-dispatch.zh.md: 409e4cbf9ea3d1b4d1bbe0cd86b494429ebb8a3d +2026-07-26-code-mode-live-parallel-dispatch.zh.md: b6169e6d243163f8444ffc7b87b741ade7631198 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md index 409e4cbf9e..b6169e6d24 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md @@ -1,23 +1,23 @@ -# Agent Note:Code Mode 的实时分发生命周期,以及复用原生契约的并行执行 +# Agent Note: Code Mode 的实时分发生命周期,以及复用原生契约的并行执行 Status: implemented [English](2026-07-26-code-mode-live-parallel-dispatch.md) | 中文 -> 范围:Code Mode UI 堆叠 PR(Pull Request)链的第三个 PR,涵盖 `tool/code-dispatch-start` 事件、web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发契约的复用。构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)与 [chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.md)之上;原生契约本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md) 所有。 +> 范围:Code Mode UI 堆叠 PR(Pull Request)链的第三个 PR,涵盖 `tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发契约的复用。构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)与 [chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.md)之上;原生契约本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md) 所有。 ## 问题 -前两个 PR 之后仍留有两个缺口。子调用行过去只在每次分发*结算*(settle)后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 Code Mode 程序,付出的延迟却是原生路径的 3 倍。 +前两个 PR 之后仍留有两个缺口。子调用行过去只在每次分发*结算*后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 Code Mode 程序,付出的延迟却是原生路径的 3 倍。 ## 决策 **一对生命周期事件,一份调度契约,与原生共用。** - **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件都保持仅日志;模型上下文不受影响;格式保持 v0。 -- **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同的 fail-closed `isConcurrencySafe` 契约),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单一驱动车道独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 -- **client 侧**:`CodeSubCall` 拓宽为 `RunningToolCall | ToolResultNode`:start 事件把运行中形状写入分发索引(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致),其结算事件则原位替换该条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 -- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实契约(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 code-mode 快照都已重新录制。 +- **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 契约),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 +- **客户端侧**:`CodeSubCall` 拓宽为 `RunningToolCall | ToolResultNode`:start 事件把运行中形状写入分发索引(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致),其结算事件则原位替换该条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 +- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实契约(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 Code Mode 快照都已重新录制。 ## 曾考虑的替代方案 @@ -25,8 +25,8 @@ Status: implemented **在提交时而非入池时发出 start 事件。** 否决:提交即发 start 会把排了队却从未运行的调用显示成「运行中」,还得强行引入第三种「已放弃」终态事件才能使日志自洽。入池才发 start 保住了*已启动 ⇔ 恰好结算一次*这一不变式,且不需要第三种事件。 -**直接复用 loop 调度器的实现。** 否决:loop 调度的是一个完整解析好的批次,并按模型顺序提交结果;桥接层调度的则是一条开放式的提交流,其结果返回给程序,而不是进入 transcript(文本记录)。因此两者共享的只是*契约*(分类、池、屏障),而不是实现机制。 +**直接复用 loop 调度器的实现。** 否决:loop 调度的是一个已完整解析的批次,并按模型顺序提交结果;桥接层调度的则是一条开放式的提交流,其结果返回给程序,而不是进入 transcript(文本记录)。因此两者共享的只是*契约*(分类、池、屏障),而不是实现机制。 ## 后果 -程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时 spec 锁定原位结算、乱序完成与 callTime 配对。PR6(trajectory/waterfall 的 span)现在可以依据这对事件的计时绘制如实的 span。spill PR(下一个)则继承结算事件,作为自己唯一施加边界的位置。 +程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。Web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时测试锁定原位结算、乱序完成与 callTime 配对。PR6(trajectory/waterfall 的 span)现在可以依据这对事件的计时绘制如实的 span。spill PR(下一个)则继承结算事件,作为自己唯一施加边界的位置。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 1faf10a4c8..dade9b5d90 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f -2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6 +2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2 +2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 98f9dc9bed..a36f7b239a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -27,10 +27,10 @@ The workspace manager must upsert the returned workspace before the selection ca The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. -Platform adapters invoke native tools without a shell: +Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Windows: the koffi `IFileOpenDialog` child process with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the tier has no fallback — failures surface as-is ([PowerShell chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index ca765778fa..bb3e2fc6f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell,直接调用原生工具: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index 9a3df405be..b0e6012183 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c -2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db +2026-07-27-skill-catalog-hot-refresh.md: 43c2da75aeb0031dffb5f47d7e76805baa2920e5 +2026-07-27-skill-catalog-hot-refresh.zh.md: 04d39dd767e5438c57d3eb29523a8ff26592465c diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 8e70fb10e7..43c2da75ae 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -18,7 +18,7 @@ The skill capability separates catalog membership from instruction-body loading. A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks. -`@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. +`@deepseek-ai/dsh-tool-skill` contributes the first non-empty complete catalog as a sourced `UserMessage` to the first entering `agent/pre-step` that observes one. At every pre-step it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest contributes a complete replacement to an enter decision, including an explicit empty catalog when all skills disappear; rejection or listener failure records nothing. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete entering observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 9a6b6d944b..04d39dd767 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -18,7 +18,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps 系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。 -`@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 +`@deepseek-ai/dsh-tool-skill` 在首次观察到非空完整目录且返回 enter 的 `agent/pre-step` 中,贡献一条带来源的 `UserMessage`。每次 pre-step,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件会向 enter 决策贡献完整替换目录;所有 skill 消失时也包含显式空目录,reject 或监听器失败则不记录任何内容。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整且进入步骤的观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml index e670431c9c..1f1ab7d8d2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: 9f5e931e56565b0bf3ee219567c6913c40ef1d97 -2026-07-27-tmux-location-context.zh.md: e47eb43c5a9c64bab4518d118cb4dfd580de64e9 +2026-07-27-tmux-location-context.md: dc9e559d1ed5cbb419901303a81e69cb0d0ba828 +2026-07-27-tmux-location-context.zh.md: 9c8a56f12406ff17ce9c25538dcfdf8e7aa06f91 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md index 9f5e931e56..dc9e559d1e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -14,7 +14,7 @@ tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and ` `@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. The shipped TUI mounts it because terminal-multiplexer context is specific to that surface; `dsh-agent-spine-demo` and the Web/headless surfaces stay silent. -**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/pre-step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. **Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. @@ -36,7 +36,7 @@ The turn preamble is the volatile first line; the two-line state block below it ### Durability and request reconstruction -Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/pre-step` listener cancels or fails the attempt, and the append-only log performs no rollback. The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. @@ -46,7 +46,7 @@ An agent booted inside tmux now receives its own session/window/pane location an ## Testing -Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/pre-step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md index e47eb43c5a..9c8a56f124 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -1,4 +1,4 @@ -# Agent Note:tmux 位置上下文 +# Agent Note: tmux 位置上下文 Status: implemented @@ -14,7 +14,7 @@ tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所 `@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。已交付的 TUI 会挂载它,因为终端复用器上下文是该界面特有的;`dsh-agent-spine-demo` 与 Web/无头界面保持沉默。 -**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/pre-step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 **通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 @@ -36,7 +36,7 @@ window active=<0|1>, pane active=<0|1>, layout ### 持久性与请求重建 -每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/pre-step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 @@ -46,7 +46,7 @@ window active=<0|1>, pane active=<0|1>, layout ## 测试 -单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/pre-step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index d7e0f75f2d..71c2325bb3 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384 -2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c +2026-07-27-trajectory-inspection-ledger.md: e3fc22234c1df449c99eac90af27de7da0b6f202 +2026-07-27-trajectory-inspection-ledger.zh.md: abf8fd3bcaeabba6061bd415acbe8ddefef7ac79 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index cdeaa30ea6..e3fc22234c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -16,13 +16,16 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. -- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. -- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative. +- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory opens the source's tail while mounted and requests one older page when the user reaches the loaded range's top, then lazily derives event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. -- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. +- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. -- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. +- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. The raw window base sequence detects a prepend even when a page adds no surface-visible node. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. +- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. Before those rebuilds, the inspection ledger drops completed-step token payloads that no projection reads while retaining the first visible token for timing, every usage chunk for accounting, and every chunk from unfinished or interrupted steps; the independent history source retains the raw entries. +- History folding rebases only the loaded surface events into a compact contiguous input for the canonical surface manager, then maps its nodes back to absolute session sequences. Structural events therefore retain canonical replacement validation without replaying token chunks or materializing synthetic events for unloaded sequences. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. @@ -32,6 +35,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. +**Mount every projected record in the table.** Rejected: record projection remains useful for search, timing, and navigation, but keeping every row and its descendants in the DOM makes browser rendering scale with the complete session instead of the visible viewport. + +**Exhaust every history page when Trajectory mounts.** Rejected: complete session metrics would be immediately available, but transporting and repeatedly projecting old chunk-heavy pages delays inspection of the current tail. On-demand backward paging makes that cost follow the user's navigation. + +**Rebuild the loaded ledger for every streamed token chunk.** Rejected: virtual rows bound DOM work but do not make repeated history folding cheap. Keeping finalized projections stable makes ordinary deltas proportional to the current partial, while structural events remain the explicit full-rebuild boundary. + **Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. @@ -44,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, incremental partial projection removes loaded-history length from ordinary token-frame work, and completed-step chunk compaction makes structural rebuilds proportional to inspection-relevant entries rather than the raw token count. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index df2a3d2661..abf8fd3bca 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -16,13 +16,16 @@ Status: implemented - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 -- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。 +- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 +- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 -- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 -- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 +- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。 +- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 @@ -32,6 +35,12 @@ Status: implemented **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 +**在表格中挂载每条投影记录。** 不予采纳:记录投影仍可用于搜索、计时和导航,但把每一行及其后代都保留在 DOM 中,会使浏览器渲染开销随完整会话增长,而非随可见视口增长。 + +**Trajectory 挂载时补齐所有历史页面。** 不予采纳:完整会话指标可以立即获得,但传输并反复投影含大量分片的旧页面会延迟对当前尾部的检查。按需向前分页会让这项成本随用户导航产生。 + +**每收到一个流式 token 分片就重建已加载记录表。** 不予采纳:虚拟行限制了 DOM 工作量,却不会让反复折叠历史变得低廉。保持已完成投影稳定,可以让普通增量的成本只随当前未完成部分增长,而结构事件仍是显式的完整重建边界。 + **不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 @@ -44,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表契约锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 6e57e3f312..6018cb1305 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md 2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 96ffd772810a7908ff452967aa0be0e540bc2d8c -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a7062bd356eb77c76799a64c980198200d140e8f +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 74ae4b81807d1267dc19f30d2bb51e50a775c4be diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index a7062bd356..74ae4b8180 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -4,46 +4,46 @@ Status: implemented [English](2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) | 中文 -## Problem +## 问题 -stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执行 Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md))当时只有一个客户端:Python SDK。想要同样"把 harness 作为子进程驱动"能力的 TypeScript 消费者——仓库测试、自动化,尤其是一个其子进程是*完整 harness 运行时*(而非通用 ACP 代理)的 subagent 后端——无物可导入:请求/通知载荷形状只以匿名对象字面量存在于服务器内部,传输类也躺在服务器插件包里。 +stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执行 Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md))当时只有一个客户端:Python SDK。想要同样「把 harness 作为子进程驱动」能力的 TypeScript 消费方——仓库测试、自动化,尤其是一个其子进程是*完整 harness 运行时*(而非通用 ACP 代理)的 subagent 后端——没有可导入的内容:请求/通知载荷形状只以匿名对象字面量存在于服务器内部,传输类也躺在服务器插件包里。 -## Decision +## 决策 -三个包,分层与既有 Python 栈完全一致,外加一个接缝注册: +三个包,分层与既有 Python 栈完全一致,外加一个 seam 注册: - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 -- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 -- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 -- **subagent 接缝增长出 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境擦除、进程树拆除)属于 `dsh-subprocess` 接缝;`subagent-acp` 经 `ctx.subprocess` 生成子进程,本后端则经 SDK 客户端生成(subprocess README 记载的 SDK 托管传输例外)并自行应用接缝的 `scrubbedParentEnv()`。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 +- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 +- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 `dsh-jsonrpc` 的服务不变(线上字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 -## Testing +## 测试 四层,依[测试政策](../../../../docs/testing.md): - **免密钥单元** —— `sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实 provider 驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 -- **免密钥 Loader 组合** —— `subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的转录都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 -- **免密钥快照** —— `examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制夹具(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本回合、bash 工具、spawn 子代理——各自钉住规范化通知流、SDK 回合结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 -- **带密钥 e2e** —— 快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交夹具由它产出);组合 e2e 设计上无需密钥。 +- **免密钥 Loader 组合** —— `subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 +- **免密钥快照** —— `examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制 fixture(测试前置数据)(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本轮次、bash 工具、spawn subagent——各自钉住规范化通知流、SDK 轮次结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 +- **带密钥 e2e** —— 快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。 -## Alternatives considered +## 考虑过的替代方案 -**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费者(包括绝不能提供 JSON-RPC 服务的 `subagent-dsh-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力接缝规则(接口/实现/消费者三包分立)已经点名了这种形态;这个传输是货真价实的双边物。 +**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费方(包括绝不能提供 JSON-RPC 服务的 `subagent-dsh-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力 seam 规则(接口/实现/消费方三个包分立)已经点名了这种形态;这个传输是货真价实的双边物。 **让 `subagent-dsh-sdk` 直说裸 JSON-RPC、绕开客户端 SDK。** 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个*使用* SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。 -**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但线协议(ACP SDK 连接 vs harness JSON-RPC)、子进程契约(任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;真正共享的 provider 侧部分移入 subagent 接缝的 `out-of-process.ts`,进程机制则住在 `dsh-subprocess` 接缝。 +**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但线协议(ACP SDK 连接 vs harness JSON-RPC)、子进程契约(任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;真正共享的 provider 侧部分移入 subagent seam 的 `out-of-process.ts`,进程机制则住在 `dsh-subprocess` seam。 -**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。 +**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费方按定义就有 Node,且仓库内消费方还有工作区;为尚不存在的消费方编造发行方案违反「只实现当前需求」的规则。推迟到真实的 npm 发行消费方出现时再处理。 -**导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现接缝;暴露它们会让调用方不得不理解客户端如何校验与分发线输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。 +**导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现 seam;暴露它们会让调用方不得不理解客户端如何校验与分发线输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。 **复用 `dsh-acp-snapshot` 的 `runScenario` 做 SDK 快照。** 那个 harness 说 ACP(`ClientSideConnection`、`InputStep` 脚本)。SDK 套件的全部意义就是以 *SDK 客户端*为入口表面;它复用 normalize/refresh 库层(`normalizeSessionLog`、`refreshFixtureReplacements`……),不动 ACP 驱动器。 -## Consequences +## 后果 -**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因,包根也只暴露归调用方所有的操作;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 +**收益**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是 seam Agent Note 所设想的递归组合方式;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 -**付出**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);线上仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧回合继续跑到进程拆除为止;快照夹具录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。 +**代价**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);线上仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧轮次会继续运行到进程清理为止;快照 fixture 录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 0aed75f807..7701e5fcc7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 9a634c586a4793d1c6986a7e7c0b0c1157b5b687 -2026-07-27-web-session-search.zh.md: 5ec2baf7443aaaaa75abc348ee426df9c14fbaa2 +2026-07-27-web-session-search.md: 4c49c0e61ca1b15181e767e4f65353312f8a3e3f +2026-07-27-web-session-search.zh.md: e9aa00656443e544bad623540d07ff785b1285ef diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 9a634c586a..4c49c0e61c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -12,7 +12,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message` and `assistant/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 5ec2baf744..e9aa006564 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -12,7 +12,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml index 5b8cb4481f..6a48bdc751 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.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/feature/2026-07-27-workspace-registration-deletion.md 2026-07-27-workspace-registration-deletion.md: ae12b09979f20385336eef8d805173dd9c08d887 -2026-07-27-workspace-registration-deletion.zh.md: 2e43b25ec0b68703d68f6346796482d1c5ed3e8a +2026-07-27-workspace-registration-deletion.zh.md: 85f399142bb0faf4b4bbf438ef2892d957814fab diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md index 2e43b25ec0..85f399142b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -1,32 +1,32 @@ -# Agent Note(agent 决策记录):删除 Workspace 注册记录 +# Agent Note: 删除 Workspace 注册记录 Status: implemented [English](2026-07-27-workspace-registration-deletion.md) | 中文 -## Problem +## 问题 Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。 -现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 +现有菜单行只有视觉呈现,没有实际功能,因此持久化顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 -## Decision +## 决策 -`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 +`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久化的 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有已持久化的会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 未知 id 在 domain seam 返回 `false`。`workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }`。`workspace.list` 仍是重连基线。 -## 持久提交与发布 +## 持久化提交与发布 -注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。 +注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久化顺序,且不会发布移除帧。 Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 -Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。 +Create 与 delete 会在记录/顺序对可能分叉之前写入持久化的 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。 ## 客户端收敛 -`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 +`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地墓碑标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次 Workspace 操作不会观察或定位到陈旧列表帧中的内容。 @@ -34,9 +34,9 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin 现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 -菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。 +菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍只有视觉呈现,没有实际功能,不在本决策范围内。 -## Alternatives considered +## 考虑过的替代方案 **级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。 @@ -48,12 +48,12 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin **成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 -## Verification +## 验证 Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 -## Consequences +## 后果 删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml index 887b18118d..89acd445d8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.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/feature/2026-07-28-sdk-max-output-tokens.md 2026-07-28-sdk-max-output-tokens.md: 3ba3e226d64b7d3d192d67bd88af463d2b0d9dc5 -2026-07-28-sdk-max-output-tokens.zh.md: aec566011d2d7a311b4de509c47ebba383c3b0d7 +2026-07-28-sdk-max-output-tokens.zh.md: 805c34f33946638bf5b9073f8bdbce156f7a36d3 diff --git a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md index aec566011d..805c34f339 100644 --- a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md @@ -1,33 +1,33 @@ -# Agent Note: SDK 最大输出 token +# Agent Note: SDK 最大输出 token 数 Status: implemented [English](2026-07-28-sdk-max-output-tokens.md) | 中文 -## Problem +## 问题 Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话模型输出。即使评测宿主要求固定输出预算,运行时仍会省略 `GenerateOptions.maxTokens`,由提供方默认值控制。`compact-basic.maxTokens` 只限制压缩摘要调用,不能承担这一职责。 -## Decision +## 决策 -高层 SDK 公开一个可选的进程级输出上限:Python 命名为 `max_tokens`,TypeScript 命名为 `maxTokens`,共享的 `initialize` 线载荷使用 `maxTokens`。JSON-RPC 服务端拒绝非正安全整数,并将通过校验的上限与提供方/模型路由一同保存。 +高层 SDK 公开一个可选的进程级输出上限:Python 命名为 `max_tokens`,TypeScript 命名为 `maxTokens`,共享的 `initialize` 协议载荷使用 `maxTokens`。JSON-RPC 服务端拒绝任何不属于正安全整数的值,并将通过校验的上限与提供方/模型路由一同保存。 -每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。Agent Loop 将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。 +每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。agent loop(智能体循环)将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。 进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。进程外提供方自行持有其独立运行时的配置;因此 `subagent-dsh-sdk` 公开独立的可选 `maxTokens`,并通过该子运行时自己的 SDK 握手传入。 压缩、会话标题生成、网页搜索和其他辅助调用继续使用各自持有的独立输出上限。`maxTokensAsSuccess` 仍然只负责结果映射,不会设置或改变上限。 -## Alternatives considered +## 考虑过的替代方案 **仅设置适配器环境变量。** 序列化器私有回退仅适用于 DeepSeek 适配器,不会出现在会话请求 header 中,对被拦截请求或其他适配器无效,也容易与提供方默认值混淆。适配器持有的默认值可以改为通过确切模型元数据公开,并在记录前填入提供方无关的请求配置。 -**在每个 `session/prompt` 上增加 `maxTokens`。** 按轮次修改会扩大线协议,并引入当前评测用例不需要的请求配置转换。运行时初始化选项可让一个 SDK 进程中的每个会话拥有相同、可重现的预算。 +**在每个 `session/prompt` 上增加 `maxTokens`。** 按轮次修改会扩充协议格式,并引入当前评测用例不需要的请求配置转换。运行时初始化选项可让一个 SDK 进程中的每个会话拥有相同、可重现的预算。 **复用 `compact-basic.maxTokens`。** 压缩值控制摘要生成,而非普通对话请求。共用会耦合两类不同 token 预算,调整一方时会静默改变另一方。 -## Consequences +## 后果 SDK 调用方无需修改 Cordis 组合即可限制模型输出,直接创建 Agent 也使用同一套经过校验的 `AgentOptions` 契约。该上限在持久化请求 header 中可见,并以 `GenerateOptions.maxTokens` 到达提供方适配器;DeepSeek 序列化会将其映射为 `max_tokens`。 -一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的 runtime 实例,或通过 agent options 显式覆盖某个进程内子级。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。 +一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的运行时实例,或通过 agent options 显式覆盖某个进程内子级。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。 diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml index b6390dffbf..84a93cde2d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.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/feature/2026-07-28-todo-plan-clears-on-next-turn.md 2026-07-28-todo-plan-clears-on-next-turn.md: 8a2808f5023a0800fbbeb65c5e28b3495e051dbc -2026-07-28-todo-plan-clears-on-next-turn.zh.md: 6ea6b2b4fafe4b2f695ad2d54561f164747e5694 +2026-07-28-todo-plan-clears-on-next-turn.zh.md: c96485ec2d5b0faea0682624cde9a7569cd2e227 diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md index 6ea6b2b4fa..c96485ec2d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -`todo_write` 在会话日志中存储整表快照,交互式宿主把最新列表渲染为计划条(web TodoPanel 经 `todos` 投影,TUI Plan 面板)。一轮结束后,该条仍留在下一用户轮次的屏幕上——上一任务已完成或已放弃的清单。读者把计划条理解为「本轮正在做什么」,因此跨轮次的陈旧列表是错误的产品生命周期。[web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)笔记仍拥有事件溯源与两个渲染面;它们把站立计划描述为持续整段会话直至下一次写入。 +`todo_write` 在会话日志中存储完整列表快照,交互式宿主把最新列表渲染为计划条(web TodoPanel 经 `todos` 投影,TUI Plan 面板)。一轮结束后,该条仍留在下一用户轮次的屏幕上——上一任务已完成或已放弃的清单。读者把计划条理解为「本轮正在做什么」,因此跨轮次的陈旧列表是错误的产品生命周期。[web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)Agent Note 仍拥有事件溯源与两个渲染面;它们把常驻计划描述为持续整段会话直至下一次写入。 ## 决策 -站立计划是其后没有更晚 `turn/start` 的最近一次 `todo/write`。`turn/end` 保留列表可见,以便用户阅读回答时仍能看到刚完成的清单;下一次 `turn/start` 将其清空,直至模型再次写入。 +常驻计划是其后没有更晚 `turn/start` 的最近一次 `todo/write`。`turn/end` 保留列表可见,以便用户阅读回答时仍能看到刚完成的清单;下一次 `turn/start` 将其清空,直至模型再次写入。 -### Host 投影(web) +### 宿主投影(web) -`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取整表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史尾页的 `projections` 块与 `session/projection` 推送帧上供给该值;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的 snapshot 使用。 +`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 ### TUI 实时路径 @@ -24,8 +24,8 @@ TUI 的 `renderEvent` 分支仍在 `turn/start` 清空本地计划面板、在 ` - **在 `turn/end` 清空**——用户仍在阅读刚完成的回答时就隐藏清单;此时计划条的职责是已完成计划,而非空 dock。 - **仅在全部项为 `completed` 时清空**——会让放弃或部分完成的计划跨轮残留;计划条仍会显示另一任务的工作。 -- **在 turn start 追加空的 `todo/write`**——为 UI 生命周期规则改写日志,并捏造模型从未写出的写入。 +- **在轮次开始时追加空的 `todo/write`**——为 UI 生命周期规则改写日志,并捏造模型从未写出的写入。 ## 后果 -Host 投影与 TUI 面板共用同一生命周期规则;重新打开会话仅在其后没有更晚轮次开始时恢复计划。部分取代 [web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)中「会话级站立计划」的表述:事件溯源、last-write-wins 替换与两个渲染面仍归那些笔记;本笔记拥有轮次边界清空。覆盖:tool-todo 投影对 turn/start 清空与 turn/end 保留的规格测试、供组装 web snapshot 的 fixture 推送帧清空,以及启动下一轮并钉住计划条消失的 TUI snapshot。 +宿主投影与 TUI 面板共用同一生命周期规则;重新打开会话仅在其后没有更晚轮次开始时恢复计划。部分取代 [web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)中「会话级常驻计划」的表述:事件溯源、last-write-wins 替换与两个渲染面仍归那些 Agent Note;本笔记拥有轮次边界清空。覆盖:tool-todo 投影对 turn/start 清空与 turn/end 保留的规格测试、供组装 web 快照的 fixture 推送帧清空,以及启动下一轮并固定计划条已消失这一结果的 TUI 快照。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 44869d6f05..9b8d037c43 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -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/feature/2026-07-28-tool-call-file-open-in-os.md 2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c -2026-07-28-tool-call-file-open-in-os.zh.md: efb4c39503d9de71a9d773bdae7fac4fb2b08ee3 +2026-07-28-tool-call-file-open-in-os.zh.md: 725db61869383711042d85cc1508b00eb1b196b6 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index efb4c39503..725db61869 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -4,27 +4,27 @@ Status: implemented [English](2026-07-28-tool-call-file-open-in-os.md) | 中文 -## Problem +## 问题 聊天工具行把整行摘要当作点击目标,点击后打开右侧 details 面板,并带有整行悬停背景。对文件系统工具而言,有用的动作是用操作系统默认应用打开所涉文件,而不是在侧栏里查看原始工具载荷。 -## Decision +## 决策 -文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径相对会话 cwd 解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是特权一元 RPC,仅接受来自回环、同源浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 -## Alternatives considered +## 考虑过的替代方案 - 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 - 在应用内预览文件 — 否决;要求是操作系统默认应用。 - 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 -## Consequences +## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行是惰性摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 -## Risks +## 风险 -- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回 internal 错误。 +- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md deleted file mode 100644 index 04bcaa26be..0000000000 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note: Address pending queue occurrences for edit and removal - -Status: implemented - -English | [中文](2026-07-29-addressable-queue-operations.zh.md) - -## Problem - -The Web queue rendered pending messages but could not edit or delete one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. - -## Decision - -**Each accepted FIFO occurrence has its own identity.** AgentLoop mints an opaque `InboxItemId` and publishes an `InboxItem` containing that id, the identified `UserMessage`, and its acceptance-time `queued | steering` placement. Reusing one `MessageId` creates distinct inbox identities. Injection bypasses the FIFOs and receives no inbox identity. - -**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Strict steer transfers the message into an open next-step window as a new steering occurrence; a closed window returns `steer-unavailable` without changing the queued item. Pending steering and driver-claimed occurrences return `not-found`, so later mutations never rewrite active-turn input or durable history. - -**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. - -**Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. - -**Web actions address Queue only.** The placement-aware `session/queue` snapshot carries both queued and pending-steering occurrences; QueueDock selects only queued items, while ChatView projects steering and retains the existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer and pending-projection contract. - -## Alternatives considered - -**Address rows by `MessageId`.** Rejected because one immutable message may be sent repeatedly; editing or deleting by message identity would affect an ambiguous occurrence. - -**Apply optimistic browser mutations.** Rejected because driver claim and another client can win before the Host action. Waiting for the authoritative snapshot makes the ownership boundary visible and lets `queue-item-not-found` report a real race. - -**Allow editing or removal of pending steering.** Rejected because QueueDock only addresses independent queued turns. Once strict steer succeeds, the new steering occurrence belongs to the active turn and remains outside this mutation surface. - -**Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use. - -**Resume a cold Agent for a queue operation.** Rejected because durable session identity does not preserve the process-local inbox capability. Resuming can only produce `not-found` after creating unrelated live state. - -## Verification - -AgentLoop contract tests hold prompt admission while editing, removing, and strictly steering exact queued occurrences; they reject mutations of steering occurrences and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed race errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, strict steer, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive all three exposed actions through the built Web composition and real HTTP/SSE wire, then stop consecutive active turns to prove the preserved FIFO advances without clearing its tail. - -## Consequences - -Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, strict transfer, broad cancellation, disposal, or restart; the Web stop action preserves queued occurrences until a later claim, while reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside the projection and operation surface. - -The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md deleted file mode 100644 index a16bf86672..0000000000 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note(agent 决策记录):为待处理队列项提供编辑与移除操作 - -Status: implemented - -[English](2026-07-29-addressable-queue-operations.md) | 中文 - -## 问题 - -Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 - -## 决策 - -**每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 - -**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。严格 steering(中途引导)会把消息作为新的 steering 单次入队项转移到开放的 next-step 窗口;窗口关闭时返回 `steer-unavailable`,且不改变 queued 项。待处理 steering 和已被驱动器认领的项会返回 `not-found`,因此后续变更绝不会改写活动轮次输入或持久历史。 - -**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 - -**Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 - -**Web 操作只面向 Queue。** 带 placement 的 `session/queue` 会同时携带 queued 和待处理 steering;QueueDock 只选择 queued 项,ChatView 则投影 steering,并在消费后沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移与待处理投影契约。 - -## 考虑过的替代方案 - -**通过 `MessageId` 寻址行。** 不予采纳,因为同一条不可变消息可以重复发送;按消息标识编辑或删除会无法确定应操作哪一次入队。 - -**在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 - -**允许编辑或移除待处理 steering。** 不予采纳,因为 QueueDock 只寻址独立的 queued 轮次。严格 steering 一旦成功,新的 steering 单次入队项就属于活动轮次,并且不再位于此变更接口内。 - -**暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。 - -**为队列操作恢复冷 Agent。** 不予采纳,因为持久会话标识不会保留进程本地的 inbox 寻址凭据。恢复只能在创建无关的实时状态后得到 `not-found`。 - -## 验证 - -AgentLoop 契约测试会在编辑、移除和严格 steering 精确 queued 入队项时保持提示词接纳窗口打开,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化竞态错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、严格 steering、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议执行公开的全部三项操作,随后连续停止活动轮次,证明保留的 FIFO 会继续推进且不清空队尾。 - -## 后果 - -queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、严格转移、广义取消、dispose 或重启时消失;Web 停止操作会将 queued 入队项保留到后续认领,而重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此投影和操作接口。 - -现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 6954c289bd..161e7b7fe7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.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/feature/2026-07-29-ask-question-web-presentation.md 2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55 -2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a +2026-07-29-ask-question-web-presentation.zh.md: d1d18c030fd6cd9fc7832f82c19b49e4e8e04d30 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index 5bb19d3a68..d1d18c030f 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Ask-question Web 呈现 +# Agent Note: Ask-question Web 呈现 Status: implemented diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml index 3ade5b93e1..8df627311b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.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/feature/2026-07-29-directory-picker-adaptive-default.md 2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4 -2026-07-29-directory-picker-adaptive-default.zh.md: a2a2d8ec4c91a347eedfc3aa3413b091ee847934 +2026-07-29-directory-picker-adaptive-default.zh.md: fd3f1473beabdc14b4eea84efdb3fe25116f759d diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md index a2a2d8ec4c..fd3f1473be 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md @@ -1,6 +1,6 @@ -# Agent Note:目录选择交互的自适应默认值 +# Agent Note: 目录选择交互的自适应默认值 -状态:已实现 +Status: implemented [English](2026-07-29-directory-picker-adaptive-default.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 5fc6eacf97..e35daaaf42 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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/feature/2026-07-29-persistent-bash-str-replace-editor.md 2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c -2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 23f80d1a5911f4f3820d526c2221d3002507d774 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index cf4d18f26d..23f80d1a59 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -1,6 +1,6 @@ -# Agent Note:持久 Bash 与字符串替换编辑器工具 +# Agent Note: 持久 Bash 与字符串替换编辑器工具 -状态:已实现 +Status: implemented [English](2026-07-29-persistent-bash-str-replace-editor.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 9cb40d0bbc..717f40df0b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.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/feature/2026-07-29-web-message-icon-actions-and-clock.md 2026-07-29-web-message-icon-actions-and-clock.md: f43f7f9c9687e4494993d7e225d11cf6446a9954 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 866fae79f6ad3ea2cb80e5443d2cf5f763562d29 +2026-07-29-web-message-icon-actions-and-clock.zh.md: a6261c65c1e9d77cea2de5624b2c9fde1278c612 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 866fae79f6..a6261c65c1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。经 memo 的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。 +Web 聊天的用户气泡已有复制、分支、编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 harness 设计稿在回答结束后展示复制、分支、时钟。流式回复不得在逐 token 输出期间闪现该操作栏。经 memo 优化的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个轮次中最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行的开头添加感知日期的本地时钟;每个轮次中最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制、分支、时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导轮次尾部的 seq,并不为轮次中间的内容传入 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架钩子。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导轮次尾部的 seq,并不为轮次中间的内容传入 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装后的界面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -20,14 +20,14 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 -**给多步骤轮次中的每一条带 text 内容的 assistant 都挂 IconActions。** 否决:轮次中间的叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该轮次中最后一条内容 assistant 拥有该座位。 +**给多步骤轮次中的每一条带 text 内容的 assistant 都挂 IconActions。** 否决:轮次中间的叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制、分支、时钟会打乱流程。只有该轮次中最后一条内容 assistant 拥有该座位。 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 **由 IconActions 决策同时定义 session fork 语义。** 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 [Web session fork 操作](2026-07-27-web-session-fork-actions.md),避免展示组件成为 session mutation 的第二正家。 -**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 +**通过 chat store 或 inject 钩子发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费方;组件本地 timeout 符合「行为钩子可拥有不订阅外部源的状态」这一客户端规则。 ## 后果 -每个轮次中最后一条已定稿的内容回答在行挂载后立刻暴露复制、分支与事件时钟;轮次中间的内容与纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控、轮次尾部 seq 门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。 +每个轮次中最后一条已定稿的内容回答在行挂载后立刻暴露复制、分支与事件时钟;轮次中间的内容与纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天、跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中记录的暂缓 footer 功能位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控、轮次尾部 seq 门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index b5068fb402..34baccb6f5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247 +2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index ed53ffe64d..c732758bc5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -12,7 +12,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma **One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. -**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). +**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). **The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 340728ab93..2dc7e0ccf5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -12,7 +12,7 @@ Status: implemented **Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 -**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 +**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 **首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。 diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml index d6f77e3e14..4571273d21 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.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/feature/2026-07-30-plan-review-presentation-intent.md 2026-07-30-plan-review-presentation-intent.md: aeab12aac308c24aa5c4b5953a60ae0f2cf6c5b5 -2026-07-30-plan-review-presentation-intent.zh.md: 4096018e374212c821675ce6ed3a2355df20edc9 +2026-07-30-plan-review-presentation-intent.zh.md: 1e52d7363ccc2fe5390a4f91aa7fe058229fe34a diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md index 4096018e37..1e52d7363c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md @@ -1,4 +1,4 @@ -# Agent Note:计划审阅是一次决定,不是一道题 +# Agent Note: 计划审阅是一次决定,不是一道题 Status: implemented diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml index 33740e2b31..f06403d181 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md -2026-07-30-queued-manual-compaction.md: 05676ef824bc62ddbdbd8895a325570e91e4bafd -2026-07-30-queued-manual-compaction.zh.md: b2e40a42451570887a194c215df07e98aa1bd864 +2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc +2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md index 05676ef824..4b7a905712 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -8,7 +8,7 @@ English | [中文](2026-07-30-queued-manual-compaction.zh.md) Automatic compaction protects the context window, but an interactive user also needs a deterministic way to condense accumulated history before pressure policy fires. Sending `/compact` as prompt text would spend a model turn and let the conversation model reinterpret a direct control action. Implementing it inside one UI would duplicate command discovery, lifecycle logging, cancellation, and backend policy. -The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A status check is insufficient: a waking send schedules the driver's claim as a microtask, leaving a same-tick interval where status still reads idle even though the prompt already has right of way. +The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A separate status check is insufficient because another caller can wake the driver between that check and the compaction operation claiming the idle phase. Compaction also needs one mutual-exclusion fact shared by manual, pressure, overflow, and explicit-range entry points. A process-local flag alone cannot explain a crash-recovered log, while a summarize-first transaction leaves no durable evidence during the expensive interval. Conversely, treating marker pairs as exclusive containers would forbid valid idle injection even though injection is explicitly non-waking and immediate between turns. @@ -22,15 +22,15 @@ This note extends the [compaction capability seam](2026-06-18-compaction-capabil The command plugin tracks each real handler promise independently of the command executor's abort-aware wait. Its composite lifecycle effect unregisters `/compact` before asynchronously draining handlers that already started, so root teardown reaches quiescence only after backend close and flush work settles. -The seam's `ManualCompactAgentContext` adds only `reserveTurnAdmission()` to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities. +The seam's `ManualCompactAgentContext` adds only `runMaintenance()` to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities. -### Idle turn admission is synchronously reservable +### Idle maintenance is synchronously claimed -`Agent.reserveTurnAdmission(): (() => void) | undefined` claims the boundary before the next ordinary turn. It succeeds only when the driver is idle, no reservation exists, and no accepted waking item already owns the next turn, including a wake whose claim is still a pending microtask. +`Agent.runMaintenance(task)` starts only from the idle phase and claims that phase before invoking the task. A waking send starts the loop immediately when idle, so whichever operation claims the phase first owns the boundary. -The reservation does not create a second queue. Later sends keep their `InboxItemId`, placement, FIFO order, and wakeup facts. `acceptsNextStep` remains false, so waking next-step input becomes an ordinary queued follow-up rather than steering. Release is idempotent and re-arms the existing driver path. `inject()` is not withheld. +Maintenance does not create a second queue. Later sends keep their `MessageId`, placement, FIFO order, and wakeup facts. Waking input remains queued until maintenance settles, then starts the existing driver path; `inject()` remains non-waking. -`whenIdle()` treats a reservation as unfinished activity, including when it holds a waking item. Lifecycle teardown still drains the driver's own activity promise rather than awaiting an external operation, so disposal can cancel and unwind without depending on the reservation holder. +`whenIdle()` treats maintenance and any waking work released behind it as unfinished activity. Cancellation aborts the agent-owned maintenance signal, and lifecycle teardown drains the same activity boundary before disposal completes. ### One parameterized transaction owns every bracket @@ -81,7 +81,7 @@ That reference also carried client-side replacement-anchor machinery to preserve ## Alternatives considered -**Check `agent.status` without reserving admission.** Rejected because an accepted waking send can still be waiting on its claim microtask while status reads idle. +**Check `agent.status` before starting maintenance.** Rejected because the check and phase claim would be separate operations; a waking send could start the driver between them. **Queue the command itself.** Rejected because `/compact` is direct control, not model input, and a prompt already accepted first must retain right of way rather than being reordered around a second command queue. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md index b2e40a4245..15a42de353 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -8,7 +8,7 @@ Status: implemented 自动压缩(compaction)可以保护上下文窗口,但交互用户还需要一种确定性方法,在压力策略触发前压缩累积的历史。把 `/compact` 作为提示词文本发送会消耗一个模型轮次,还会让会话模型重新解释一项直接控制操作。在某个 UI 内实现该功能,则会重复命令发现、生命周期日志记录、取消与后端策略。 -面向用户的命令在轮次之间到达,并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。仅检查状态并不足够:唤醒发送会把驱动器的认领安排为 microtask,因此在同一 tick 内存在一段间隔,此时状态仍显示 idle,但提示词已经拥有优先权。 +面向用户的命令在轮次之间到达,并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。单独检查状态并不足够,因为另一调用方可能在该检查与压缩操作认领 idle phase 之间唤醒驱动器。 手动、压力、溢出和显式范围入口点还需要共享同一项互斥事实。仅使用进程本地标志无法解释一份崩溃恢复后的日志,而先摘要再记录的事务在开销较大的等待期间不会留下持久证据。反过来,把标记对视为排他容器又会禁止有效的空闲注入,尽管注入按定义不会唤醒,并且会在轮次之间立即执行。 @@ -22,15 +22,15 @@ Status: implemented 命令插件会独立跟踪每个实际处理器 promise,不依赖命令执行器的中止感知等待。其复合生命周期 effect 先注销 `/compact`,再异步等待所有已开始的处理器结算,因此根级 teardown 只有在后端的闭合与 flush 工作结算后才会完全停稳。 -该 seam 的 `ManualCompactAgentContext` 只在压缩已需使用的会话与路由事实之上增加 `reserveTurnAdmission()`。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。 +该 seam 的 `ManualCompactAgentContext` 只在压缩已需使用的会话与路由事实之上增加 `runMaintenance()`。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。 -### 可以同步预留空闲轮次接纳 +### 同步认领空闲维护阶段 -`Agent.reserveTurnAdmission(): (() => void) | undefined` 会在下一个普通轮次之前认领边界。仅当驱动器空闲、没有既存预留,而且尚无已获接纳的唤醒项拥有下一轮次时,它才会成功;仍在等待 microtask 认领的唤醒项也包括在内。 +`Agent.runMaintenance(task)` 只能从 idle phase 启动,并会在调用任务前认领该 phase。会唤醒的发送会在 idle 时立即启动循环,因此先认领 phase 的操作会拥有该边界。 -该预留不会创建第二个队列。之后发送的项保留其 `InboxItemId`、位置、FIFO 顺序与唤醒信息。`acceptsNextStep` 保持 false,因此唤醒的 next-step 输入会成为普通的排队 follow-up,而不是 steering(中途引导)。释放操作可幂等调用,并重新启用既有驱动器路径。`inject()` 不受阻塞。 +维护阶段不会创建第二个队列。之后发送的项保留其 `MessageId`、位置、FIFO 顺序与唤醒信息。会唤醒的输入会保持排队,直至维护任务结算,再启动既有驱动器路径;`inject()` 仍然不会唤醒驱动器。 -`whenIdle()` 会把预留视为尚未完成的活动,包括预留持有唤醒项的情况。生命周期 teardown 仍会排空驱动器自身的 activity promise,而不会等待外部操作,因此 dispose(资源释放)可以执行取消并完成退出清理,无需依赖预留持有方。 +`whenIdle()` 会把维护任务及其结算后释放的所有唤醒工作视为尚未完成的活动。取消会中止 agent 自有的维护信号,生命周期 teardown 则会在 dispose(资源释放)完成前排空同一个活动边界。 ### 一个参数化事务拥有每一对标记 @@ -81,7 +81,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试 ## 曾考虑的替代方案 -**仅检查 `agent.status`,不预留接纳。** 不予采用,因为已获接纳的唤醒发送可能仍在等待其认领 microtask,而状态仍显示 idle。 +**在启动维护任务前检查 `agent.status`。** 不予采用,因为检查与 phase 认领会成为两个独立操作;会唤醒的发送可能在二者之间启动驱动器。 **把命令本身加入队列。** 不予采用,因为 `/compact` 是直接控制而非模型输入;先获接纳的提示词必须保留优先权,不能围绕第二个命令队列重新排序。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 70b35c2ff0..624da25dc2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b5bc3547e0f72ba8211f4338fb4c1f5223a3d0cc -2026-07-30-web-queue-steer-action.zh.md: 6edde8e2cc2bee0a52a0b05df8f62f77b3d9ccfa +2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac +2026-07-30-web-queue-steer-action.zh.md: b04902b8a8a0d727b01aa6ba5562e12cc5d36c92 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b5bc3547e0..b04095b81f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-web-queue-steer-action.zh.md) ## Problem -The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. +The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed steer events as user-style bubbles, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. Implementing the row action as a client-side delete followed by `session.prompt(mode: 'steer')` would split one user intent across two RPCs. Driver claim could win between them, the steer could fail after deletion, or the existing best-effort `agent.steer()` fallback could silently append a new Queue item after the original occurrence was removed. A send-now action must therefore distinguish current-turn steering from Queue promotion and preserve the original row when steering is no longer possible. @@ -16,7 +16,7 @@ Implementing the row action as a client-side delete followed by `session.prompt( Each non-editing ordinary-session QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. An addressed subagent keeps its Queue projection read-only because its continuation transport does not expose queue mutation. -Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `steering/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. +Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `user/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. @@ -36,13 +36,13 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. -When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. +When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. ### Verification -AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value and delivery receipt, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. +AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value and delivery receipt, drains as a `user/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows and Queue-only subagent gestures. Runtime and ChatView tests cover occurrence-aware pending-to-durable handoff, including repeated `MessageId` values, while Web ARIA snapshots cover pending steering after the running-status row with Copy alone and the durable node with clock, Copy, and Fork. @@ -66,6 +66,6 @@ The keyless Web steering scenario queues a message through the real composer whi ## Consequences -`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until `steering/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so an enabled action may internally return `steer-unavailable` while the product continues through Queue without reporting failure. +`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until the durable `user/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so an enabled action may internally return `steer-unavailable` while the product continues through Queue without reporting failure. The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 6edde8e2cc..b04902b8a8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 +Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 steer 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。 @@ -16,7 +16,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 普通会话中每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `steering/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `user/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 @@ -36,13 +36,13 @@ Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲 Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 +AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 ### 验证 -AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 +AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `user/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖按单次入队项完成的待处理到持久交接,包括重复的 `MessageId` 值;Web ARIA 快照则覆盖位于运行状态行之后且仅有复制的待处理 steering,以及带时钟、复制和 fork 的持久节点。 @@ -66,6 +66,6 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、带 placeme ## 后果 -`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在 `steering/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此已启用的操作可能会在内部返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 +`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在持久 `user/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此已启用的操作可能会在内部返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml index 7efb9e75d5..388eb85ef8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md -2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe -2026-07-30-web-read-card-frontend.zh.md: b6314f21ba3eb2283788374b10c77ed22e26d16c +2026-07-30-web-read-card-frontend.md: 06559d70c655b86a6aa70c8a9e948f4f21a1f522 +2026-07-30-web-read-card-frontend.zh.md: 92fb724658d4c23b915f61efc3b482fdf1ce7c7b diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md index f504cab770..06559d70c6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -16,7 +16,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car `readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache. -The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface. +The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` through `ctx.slots.inject`, exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface. Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md index b6314f21ba..92fb724658 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md @@ -16,7 +16,7 @@ Status: implemented `readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView`(`kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。 -聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序接缝为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。 +聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(经 `ctx.slots.inject` 以 `read` 键注册,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。 整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index a6658971b1..2d2b338b6f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.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/feature/2026-07-30-web-search-card.md 2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1 -2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95 +2026-07-30-web-search-card.zh.md: b129d411b9b402b18d6b4ec94dad544effd3bb8c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index 714a297973..b129d411b9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Web 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器 +# Agent Note: Web 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器 Status: implemented diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml index d4f7f72fe2..efc896170b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.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/feature/2026-07-31-hover-card-click-copy.md 2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 -2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904 +2026-07-31-hover-card-click-copy.zh.md: 2d3bc893dd617a3e2e21431175c54bcd4b7ed598 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md index a57b5238b0..2d3bc893dd 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -1,4 +1,4 @@ -# Agent Note(agent 决策记录):悬浮卡片激活时复制主要值 +# Agent Note: 悬浮卡片激活时复制主要值 Status: implemented diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml index 0eddfa1740..025c838a7d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.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/feature/2026-07-31-third-party-memory-mcp-examples.md 2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f -2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c +2026-07-31-third-party-memory-mcp-examples.zh.md: 18502f0c30fdbd62ffe49e34c587785d123046d2 diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md index ee0e9f2e13..18502f0c30 100644 --- a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md @@ -37,7 +37,7 @@ Status: implemented | MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | | Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | -存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包(package)目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。 +存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。 项目身份仍由提供方负责:Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml index 483e4cc132..991243278a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.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/feature/2026-07-31-web-cards-toolrow.md 2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033 -2026-07-31-web-cards-toolrow.zh.md: 4e9c429497e1265b4b39ed2479f382a2f36e7741 +2026-07-31-web-cards-toolrow.zh.md: 7eb53a163f1fd22e09fcf498cb3e6b138834a2f3 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md index 4e9c429497..7eb53a163f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md @@ -1,4 +1,4 @@ -# Agent Note:卡片工具行通过同一个 ToolRow 折叠 +# Agent Note: 卡片工具行通过同一个 ToolRow 折叠 Status: implemented diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml new file mode 100644 index 0000000000..d4ec255235 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 +2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md new file mode 100644 index 0000000000..7206f8ffe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell executor and pwsh tool + +Status: implemented + +English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) + +## Problem + +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. + +## Decision + +Two new packages under `packages/bash/`: + +- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. + +Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. + +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). + +## Alternatives considered + +**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. + +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. + +**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. + +## Consequences + +- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. +- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md new file mode 100644 index 0000000000..5a48adb79f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell 执行器与 pwsh 工具 + +Status: implemented + +[English](2026-08-01-pwsh-tool-and-executor.md) | 中文 + +## 问题 + +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 + +## 决策 + +在 `packages/bash/` 下新增两个包: + +- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 + +Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 + +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 + +## 备选方案 + +**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 + +**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 + +**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 + +## 后果 + +- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 +- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 +- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml new file mode 100644 index 0000000000..eea7ced3d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee +2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md new file mode 100644 index 0000000000..945d2d5243 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh tool bash parity + +Status: implemented + +English | [中文](2026-08-02-pwsh-tool-bash-parity.zh.md) + +## Problem + +The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only (a fresh process per call; no persistent PTY session), no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. + +## Decision + +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: + +- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. +- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. +- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. + +## Alternatives considered + +**Keep the minimal profile and fix only the claims.** Rejected: the review's core finding was that text contracts copied from bash drift without the corresponding implementation; a minimal tool plus accurate claims still leaves pwsh calls without background execution, without contributor parity, and with a divergent marker story that must be re-justified forever. + +**Reject a mismatched executor dialect at load.** Attempted and reverted before merge: a `ShellDialect` marker (`bash` | `powershell`) on `BashExecutor`, with both shell tools throwing when the mounted executor speaks another shell. It forced every executor implementation — including each test and example fake — to declare a dialect, adding noise to every shell-tool test for a guard with no in-repo or plausible deployment to catch (shipped compositions always pair tool-pwsh with `dsh-pwsh-local` and tool-bash with `dsh-bash-local`). The pairing contract stays documented in each tool's README instead. + +**Extract a fully shared tool implementation base (abstract shell dialect, two thin leaves).** Considered and deferred: the bash-env extraction and the structural mirror (`render.ts`/`background.ts` twins) are the foundation it would rest on; a full base waits until a third dialect or the persistent-PTY twin makes the abstraction's shape observable. + +## Consequences + +- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. +- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. +- The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. +- The roadmap proposal's parity stage is delivered; the terminal-card presentation stage shipped in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision (the TUI itself was removed), leaving the Windows default composition as the remaining stage. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md new file mode 100644 index 0000000000..f537e313a0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh 工具与 bash 对齐 + +Status: implemented + +[English](2026-08-02-pwsh-tool-bash-parity.md) | 中文 + +## 问题 + +首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台、无后台任务、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事("恒打 `[exit code: N]`")。对该变更的 review 发现模型可见契约与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。 + +## 决策 + +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: + +- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 +- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 +- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 + +## 备选方案 + +**保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。 + +**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对契约改由各工具 README 记录。 + +**提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。 + +## 后果 + +- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 +- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 +- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 +- 路线图提案的 parity 阶段已交付;terminal 卡呈现阶段随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策交付(TUI 本身已移除),剩余阶段是 Windows 默认组合。 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml new file mode 100644 index 0000000000..2ec7925a3e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-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 .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 +2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md new file mode 100644 index 0000000000..91a1ed0d7b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -0,0 +1,27 @@ +# Agent Note: Win32 folder picker moves to koffi in a child process + +Status: implemented + +English | [中文](2026-08-02-win32-in-process-folder-dialog.zh.md) + +## Problem + +The Windows directory picker's primary tier was a spawned PowerShell script around WinForms `FolderBrowserDialog`: the modern dialog only where PowerShell 7 happens to be installed, a review-flagged regression where PowerShell 6 resolves but has no WinForms (exit 1 is not `ENOENT`, so the 5.1 fallback never ran), a `SetProcessDPIAware` ceiling of system DPI, and a picker whose behavior depended on which shells a machine ships rather than on Windows itself. + +## Decision + +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs in a spawned child process so the modal `Show` never blocks the host event loop; the child posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), killing the child when the close budget is exhausted. The dialog is the child's first window, so Windows activates it without a foreground call. The child thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The PowerShell chain that preceded this tier is gone (see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)): the tier has no fallback. + +## Alternatives considered + +- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. +- **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. +- **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. + +## Consequences + +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md new file mode 100644 index 0000000000..6b90dc1c5f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Win32 文件夹选择器迁至 koffi 子进程 + +Status: implemented + +[English](2026-08-02-win32-in-process-folder-dialog.md) | 中文 + +## 问题 + +Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` 的外部 PowerShell 脚本:只有恰好安装了 PowerShell 7 的机器才有现代对话框;review 指出的回归——PowerShell 6 可解析却没有 WinForms(退出码 1 而非 `ENOENT`,5.1 回退永远不会触发);`SetProcessDPIAware` 只有系统 DPI 的上限;选择器的行为取决于机器装了哪些 shell,而不是取决于 Windows 本身。 + +## 决策 + +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 spawn 出的子进程中,模态 `Show` 永不阻塞宿主事件循环;子进程在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,关闭预算耗尽时 kill 子进程。对话框是子进程的第一个窗口,Windows 会自动激活它,无需手动前台调用。子进程线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。先于本层存在的 PowerShell 链已被删除(见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)):该层无回退。 + +## 考虑过的替代方案 + +- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 +- **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 +- **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 + +## 后果 + +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾)。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的臂——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码平面与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml new file mode 100644 index 0000000000..b35cca4b38 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md +2026-08-04-web-composer-shared-width-axis.md: 96cddda25bf79e9f2df7a0298039c27f316befca +2026-08-04-web-composer-shared-width-axis.zh.md: 9a9f5a513bbce8f3e97698d58ab48b0abdefb142 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md new file mode 100644 index 0000000000..96cddda25b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md @@ -0,0 +1,31 @@ +# Agent Note: Web composer shared width axis and control-row polish + +Status: implemented + +English | [中文](2026-08-04-web-composer-shared-width-axis.zh.md) + +## Problem + +The web conversation column sized each surface independently: the transcript column, the input card, the todo/goal/queue dock cards, and the ask-question/approval/plan-review takeover cards each carried their own hardcoded max-width (736/752/776/800px variants) and their own side paddings. The surfaces drifted a few pixels apart at full width and diverged further on narrow viewports, where some panels kept clearance from the screen edge and others went flush. Separately, the composer's control row had no adaptive behavior — on a narrow card the permission trigger's label squeezed the row — and the overlay menus anchored to the card could render wider than the card itself, painting past its right edge. + +## Decision + +One content width variable owns the whole column. `--dsh-chat-content-width` (748px) is declared on ConversationRoot's `.root` — the transcript and the composer seat are sibling subtrees, so the declaration must sit on their common ancestor for CSS custom-property inheritance to reach both. Every other geometry derives from it: the input card caps at `content + 32px` (`--dsh-composer-card-max-width`), the dock cards subtract four dock insets (4 × 8px) from the card and land back on the content width, and the takeover cards use the content width directly. The narrow-viewport invariant is expressed structurally, not numerically: content-width surfaces pad `calc(var(--dsh-composer-side-clearance) + 16px)` per side while the input card clears the bare clearance (16px), so "input card = content + 32px" holds at every viewport width, not just at the cap. + +The control row inside the card is a `container-type: inline-size` container, and the permission trigger drops its text label (keeping glyph + chevron) under a 460px container query. The query is anonymous on purpose: CSS modules hash `container-name` per module, so a name declared in InputBar's sheet can never match a query written in PermissionSelect's sheet — the two hashed names silently differ and the query never fires. Only triggers that carry a mode glyph collapse (`:has(.triggerIcon)`); a host-configured mode without one keeps its text as its sole identifier. + +Overlay menus anchored to the card (slash menu, command popupSelect) clamp to the anchor's width (`max-width: min(, 100%)`), truncating long rows with ellipses instead of overflowing the card. Tooltip bubbles keep a 12px viewport-edge safety margin in the clamp (ui-primitives Tooltip). + +## Alternatives considered + +**Keep per-surface widths and align the numbers by hand.** Rejected: the drift this change removes was exactly the residue of hand-aligned constants; any future width change would need five coordinated edits with nothing enforcing the relation. + +**Declare the variables on `.composerStack`.** Rejected after trying it: the takeover panels are siblings of the stack in the composer seat and the transcript is a different subtree entirely, so the variables never reached them; the common ancestor (`.root`) is the only correct home. + +**A named container query for the label collapse.** Rejected by measurement: CSS modules scope `container-name` per module, so the cross-module name never matched and the query was dead. The anonymous query resolves against the nearest ancestor container, which is unambiguous here (the row is the only container). + +**JS ResizeObserver for the label collapse.** Rejected: a container query is declarative, needs no listener lifecycle, and the 460px threshold is a design choice either way. + +## Consequences + +Changing the column width is now a one-line edit with the ratio relations preserved by construction, which the 736 → 748 retune during review already exercised. The cost is indirection: the widths of five surfaces are no longer readable off their own stylesheets and require following the variable chain to ConversationRoot. The container-query collapse adds the constraint that InputBar's row stays a size container; removing that declaration silently disables the permission trigger's adaptive behavior. The anonymous query also means any future second container between the row and the trigger would capture it — if that happens, the query must move or the intermediate container must be avoided. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md new file mode 100644 index 0000000000..9a9f5a513b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Web 输入区共享宽度轴与控制行打磨 + +Status: implemented + +[English](2026-08-04-web-composer-shared-width-axis.md) | 中文 + +## Problem + +Web 会话列的各个界面各自独立设定尺寸:转录列、输入卡片、todo/goal/queue 停靠卡片、ask-question/approval/plan-review 接管卡片各自硬编码 max-width(736/752/776/800px 等变体)与各自的侧边内边距。这些界面在全宽下彼此漂移几个像素,在窄视口下偏差更大——有的面板保留了到屏幕边缘的间隙,有的却贴边。另外,输入卡片的控制行没有自适应行为——窄卡片下权限触发器的文字标签会挤压整行;锚定在卡片上的浮层菜单也可能渲染得比卡片更宽,越过其右边缘。 + +## Decision + +一个内容宽度变量拥有整列。`--dsh-chat-content-width`(748px)声明在 ConversationRoot 的 `.root` 上——转录与 composer 座位是兄弟子树,声明必须放在共同祖先上,CSS 自定义属性才能通过继承同时到达两者。其他几何全部由它推导:输入卡片上限为 `content + 32px`(`--dsh-composer-card-max-width`),停靠卡片从卡片宽度中减去四个停靠 inset(4 × 8px)正好回到内容宽度,接管卡片直接使用内容宽度。窄视口不变式以结构而非数值表达:内容宽度的界面每侧 pad `calc(var(--dsh-composer-side-clearance) + 16px)`,而输入卡片只留裸 clearance(16px),因此"输入卡片 = 内容 + 32px"在任意视口宽度下都成立,而不只是在上限处。 + +卡片内的控制行是一个 `container-type: inline-size` 容器,权限触发器在 460px 容器查询下收起文字标签(保留图标 + 下拉箭头)。查询刻意匿名:CSS modules 按模块哈希 `container-name`,InputBar 样式表里声明的名字永远无法匹配 PermissionSelect 样式表里写的查询——两个哈希后的名字悄然不同,查询永不触发。只有带模式图标的触发器才收起(`:has(.triggerIcon)`);没有图标的宿主自定义模式保留文字作为其唯一标识。 + +锚定在卡片上的浮层菜单(slash 菜单、command popupSelect)钳制到锚点宽度(`max-width: min(<设计上限>, 100%)`),过长的行以省略号截断而不是溢出卡片。Tooltip 气泡在钳制中保留 12px 的视口边缘安全距离(ui-primitives Tooltip)。 + +## Alternatives considered + +**保留各界面独立宽度,手工对齐数值。** 否决:本次改动消除的漂移正是手工对齐常量的残留;未来任何宽度调整都需要五处协同编辑,且没有任何机制强制这组关系。 + +**把变量声明在 `.composerStack` 上。** 尝试后否决:接管面板在 composer 座位中是 stack 的兄弟节点,转录更是完全不同的子树,变量根本到不了它们;共同祖先(`.root`)是唯一正确的家。 + +**用命名容器查询实现标签收起。** 经实测否决:CSS modules 按模块作用域化 `container-name`,跨模块名字永不匹配,查询是死的。匿名查询解析到最近的祖先容器,在这里没有歧义(该行是唯一的容器)。 + +**用 JS ResizeObserver 实现标签收起。** 否决:容器查询是声明式的,无需监听器生命周期,而 460px 阈值无论哪种方案都是设计选择。 + +## Consequences + +修改列宽现在是一行编辑,比例关系由构造保证——评审期间 736 → 748 的重调已经验证了这一点。代价是间接性:五个界面的宽度不再能从各自的样式表直接读出,需要沿变量链追到 ConversationRoot。容器查询收起增加了一个约束:InputBar 的行必须保持为尺寸容器;删掉那条声明会静默禁用权限触发器的自适应行为。匿名查询也意味着未来若在行与触发器之间出现第二个容器,它会截获该查询——届时查询必须迁移,或避免中间容器。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml new file mode 100644 index 0000000000..f9c6976a0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +2026-08-04-web-context-source-and-steer-marks.md: 9070ea6ed34fffecd9fd2b90275bd31155100c75 +2026-08-04-web-context-source-and-steer-marks.zh.md: 9d7c7c0a34587071e281ff8b2cb77e359a1580c1 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md new file mode 100644 index 0000000000..9070ea6ed3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -0,0 +1,49 @@ +# Agent Note: Web transcript marks context source, recall, and steering + +Status: implemented + +English | [中文](2026-08-04-web-context-source-and-steer-marks.zh.md) + +## Problem + +Everything a producer adds to the model-facing conversation reached the Web transcript as one of two anonymous shapes. Every logged non-user `user/message` — the skill catalog, the runtime snapshot, reconciled `AGENTS.md` instructions, a guard notice, a subagent report, a cross-session snapshot — collapsed into one identical `上下文注入` row, so a reader could not tell what had been added without expanding each row and reading raw JSON. Mid-turn steering was worse: it rendered in exactly the bubble a turn-opening prompt uses, leaving the transcript unable to say which message interrupted a running turn. + +The distinctions are already durable. `user/message.source` is the merge-extensible provenance every producer must supply, while `agent/inbox/spliced` records whether an identified message entered and left `next-turn` or `next-step`; only the presentation discarded them. The terminal transcript this Web UI replaced did name each card's producer, so the Web surface was a regression for the same log. + +## Decision + +The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. + +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). + +**The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. + +`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. + +`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. + +## Alternatives considered + +**Localize producer names in the client.** A dictionary keyed by plugin id would read better than `@deepseek-ai/dsh-system-prompt`, but it drifts silently on every rename, needs a client change per new producer, and cannot name a producer from a foreign log at all. Provenance the log already carries is worth more than prose the client invents. + +**Register presentations per source kind.** The disclosure decision deferred a keyed context-view slot until source-owned presentations emerged. Naming a row is not a distinct presentation, and a registry keyed on mounted producers would fail exactly where it matters — a resumed log whose producer is no longer mounted still has to render. + +**Compute the role and label on the host.** The host would have to attach a view to each event copy, duplicating what the durable source already states and adding a wire field per context message. The projection derives it once per node instead, where the transcript's other derived facts live. + +**Give steering its own row instead of a captioned bubble.** Steering is a user message that arrived mid-turn; a separate row shape would break the right-aligned reading rhythm and duplicate the bubble's copy and branch actions for no new information. + +**Extend the trajectory table with the same names.** Out of scope: the table's context cell has its own text derivation, and the issue asks for the conversation surface. + +## Testing + +- `packages/client/runtime` unit coverage pins each provenance arm, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. +- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. + +## Consequences + +- A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. +- Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better name records better provenance. +- `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. +- `SteeringMessageNode` remains a distinct presentation node even though the agent loop now records admitted steering as `user/message`; its identity comes from the durable inbox history rather than a separate message event. +- The `recall` arm has no producer in a shipped Web leaf until a host mounts `dsh-session-reference`; it is reachable only through logs written elsewhere. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md new file mode 100644 index 0000000000..9d7c7c0a34 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -0,0 +1,49 @@ +# Agent Note:Web transcript 标出上下文来源、召回与 steering + +Status: implemented + +[English](2026-08-04-web-context-source-and-steer-marks.md) | 中文 + +## Problem + +生产方向模型侧对话补充的一切内容,进入 Web transcript(文本记录)后只剩两种匿名形态。每一条已记录的非用户 `user/message`——skill 目录、运行时快照、经过对账的 `AGENTS.md` 指令、guard 提示、子 agent 汇报、跨会话快照——都塌缩成同一行 `上下文注入`,读者不逐行展开去读原始 JSON 就无从知道究竟注入了什么。steering(中途引导)的情况更糟:它渲染成与开轮提示完全相同的气泡,于是 transcript 无法说明哪一条消息打断了正在运行的轮次。 + +这些区分本来就是持久事实。`user/message.source` 是每个生产方都必须提供的可合并扩展来源,`agent/inbox/spliced` 则记录有身份的消息是从 `next-turn` 还是 `next-step` 进入和离开;把这些事实丢掉的只有呈现层。被这套 Web UI 取代的终端 transcript 本来会写出每张卡片的生产者,因此面对同一份日志,Web 侧是一次倒退。 + +## Decision + +transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 + +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 + +**名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 + +`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 + +`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 + +## Alternatives considered + +**在客户端本地化生产者名称。** 以插件 id 为键的字典读起来确实比 `@deepseek-ai/dsh-system-prompt` 好,但它会在每次重命名时悄悄失准,每新增一个生产者都要改客户端,而且对来自外部的日志根本无法命名。日志已经承载的来源,比客户端自己编出来的措辞更有价值。 + +**按来源 kind 注册呈现。** 展开项决策把键控的 context-view 槽位推迟到出现由来源自有的呈现需求为止。为一行命名并不构成独立呈现,而以「已挂载的生产者」为键的注册表恰恰会在最要紧的地方失效——生产者已不再挂载的恢复日志同样必须渲染出来。 + +**在 host 侧计算角色与名称。** 那需要为每份事件副本附加一个视图,重复陈述持久来源已经说明的事实,并为每条上下文消息增加一个 wire 字段。改由投影为每个节点计算一次,与 transcript 其他派生事实同处一地。 + +**给 steering 独立的行而非带标注的气泡。** steering 是一条在轮次中途抵达的用户消息;独立行形会打断右对齐的阅读节奏,并且要为零新增信息重复气泡上的复制与分支操作。 + +**把同一套名称扩展到 trajectory 表格。** 不在本次范围内:该表格的上下文单元格有自己的文本推导,而 issue 要求的是对话面。 + +## Testing + +- `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 + +## Consequences + +- 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 +- 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好名称的生产者应当记录更好的来源。 +- `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 +- 即使 agent loop 现在把已经接纳的 steering 记录为 `user/message`,`SteeringMessageNode` 仍是独立的呈现节点;它的身份来自持久 inbox 历史,而不是独立消息事件。 +- 在某个宿主挂载 `dsh-session-reference` 之前,`recall` 分支在已发布的 Web 叶子中没有生产者,只能通过别处写入的日志抵达。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml new file mode 100644 index 0000000000..16ccf1cff3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md +2026-08-04-web-latency-throughput-metrics.md: 4d7627a9a38127259f1ba07121cea7282f794f2e +2026-08-04-web-latency-throughput-metrics.zh.md: 09e59242b799a3f4d03b9e0259f1a5a0d85f6a53 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md new file mode 100644 index 0000000000..4d7627a9a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md @@ -0,0 +1,31 @@ +# Agent Note: Web turn and window latency/throughput metrics + +Status: implemented + +English | [中文](2026-08-04-web-latency-throughput-metrics.zh.md) + +## Problem + +The Web chat records per-step LLM timing (`stepStartTime` / `firstTokenTime` / `completedTime`) and per-step usage, and the trajectory view exposes them per step, but the chat surface answers neither "how responsive was this turn" nor "how fast is this session going": the assistant footer shows only the turn wall time, and the stats line folds only wall-time totals. + +## Decision + +A package-local fold, `ui-conversation`'s `chat/turn-metrics.ts`, is the single derivation from assistant nodes to latency/throughput readings. `assistantStepReading` turns one node into a step reading: TTFT needs both `stepStartTime` and `firstTokenTime`, decode span needs `firstTokenTime`, negative spans clamp to zero, and output tokens come from the untrusted `usage` value only when they are finite and non-negative. `deriveTurnMetrics` folds readings per turn: the lowest-numbered step owns the turn's TTFT slot, and throughput divides the summed output tokens by the summed decode spans over exactly the steps carrying both, so an unsampled step drops out instead of skewing the ratio; a turn with neither figure emits no entry. + +The assistant footer appends the readings to the existing hover-revealed time chrome after `Ran for`, as `TTFT {s}s · {tps} tok/s`, each omitted independently when unrecorded. ChatView shows a turn's readings only when that turn's `turnTimings` entry has an `endTime`: the loaded window is a contiguous log suffix, so an in-window settled turn carries every one of its steps and the first-step TTFT is genuine rather than a window artifact. `formatLatencySeconds` is unit-less so each locale template owns its second suffix (`TTFT {seconds}s` / `首 token {seconds}秒`). + +The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English) beside the LLM/tool wall times. The turn-count, step-count, duration, cache, and token labels use the same namespace. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections. + +## Alternatives considered + +**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is already documented as window-scoped for its duration facts — the new group joins that scope. A later PR can add the durable projection without moving these readings. + +**Per-step footer chrome.** Showing each assistant message its own TTFT would attach chrome to mid-turn narration nodes, which the footer design deliberately keeps chrome-free; the trajectory view already exposes per-step timing detail. + +**Gating footer metrics on node presence instead of `turn/end` timing.** Rendering whatever steps happen to be loaded would show a plausible-looking TTFT that is actually the first *loaded* step after paging. The `endTime` gate plus the suffix-window invariant makes the displayed figure the turn's true first-step latency or nothing. + +## Consequences + +A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput with localized labels beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation. + +Both readings divide by measured wall time, so neither is reproducible: the same replayed scenario yielded 69 and 70 tok/s on consecutive local runs, and a 3 ms replayed stream reads 26333 tok/s. The Web aria goldens therefore normalize throughput to `{{throughput}}` beside the existing `{{duration}}`, and the footer's decorative separators gained flanking spaces — without them the readings concatenate into one accessible string (`Ran for 13sTTFT 0.2s12 tok/s`), which both loses the reading boundaries a screen reader needs and denies `{{duration}}` the word boundary it matches on. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md new file mode 100644 index 0000000000..09e59242b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Web 轮次与窗口级延迟/吞吐指标 + +Status: implemented + +[English](2026-08-04-web-latency-throughput-metrics.md) | 中文 + +## 问题 + +Web 聊天已经记录了逐步骤的 LLM 计时(`stepStartTime`/`firstTokenTime`/`completedTime`)和逐步骤 usage,trajectory 视图也按步骤展示它们,但聊天界面既回答不了「这一轮响应有多快」,也回答不了「这个会话跑得有多快」:assistant 页脚只显示轮次实际耗时,统计行也只折算墙钟时间总量。 + +## 决策 + +包内折算 `ui-conversation` 的 `chat/turn-metrics.ts` 是从 assistant 节点推导延迟/吞吐读数的唯一位置。`assistantStepReading` 把一个节点转成一次步骤读数:TTFT(首 token 延迟)需要 `stepStartTime` 与 `firstTokenTime` 同时存在,解码时长需要 `firstTokenTime`,负时长收敛为零,输出 token 数只在不可信的 `usage` 值有限且非负时才采纳。`deriveTurnMetrics` 按轮次折算读数:编号最小的步骤拥有该轮次的 TTFT 槽位,吞吐用「同时携带两者的那些步骤」的输出 token 总和除以解码时长总和,因此缺采样的步骤直接退出而不是让比值失真;两个数字都没有的轮次不产生条目。 + +assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、`用时` 之后,形如 `首 token {s}秒 · {tps} tok/s`,未记录的数字各自省略。ChatView 仅在该轮次的 `turnTimings` 条目带有 `endTime` 时才显示读数:已加载窗口是日志的连续后缀,因此窗口内已结算的轮次必然带着它的全部步骤,首步 TTFT 是真实值而非窗口截断的产物。`formatLatencySeconds` 不带单位,各语言模板各自拥有秒后缀(`TTFT {seconds}s`/`首 token {seconds}秒`)。 + +统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和/计数与解码时长/token 数,在 LLM/工具墙钟时间旁渲染经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`)。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。与那些墙钟时间一样,该分组是窗口作用域的,不折算任何计费;token 账目仍归 token-meter 投影。 + +## 考虑过的替代方案 + +**持久的会话投影(token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema,而聊天统计行的耗时事实本就被记录为窗口作用域——新分组沿用该作用域。后续 PR 可以在不挪动这些读数的情况下补上持久投影。 + +**逐步骤页脚附属元素。** 让每条 assistant 消息显示自己的 TTFT,会给轮次中段的叙述节点挂上附属元素,而页脚设计刻意让它们保持无 chrome;trajectory 视图已经暴露逐步骤计时细节。 + +**用节点是否在场而非 `turn/end` 计时做页脚门控。** 直接渲染碰巧加载到的步骤,会展示一个貌似合理、实为分页后「首个已加载步骤」的 TTFT。`endTime` 门控加上后缀窗口不变量,使显示的数字要么是该轮次真实的首步延迟,要么什么都不显示。 + +## 后果 + +窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token`/`tok/s`,统计行在墙钟时间旁以本地化标签显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化:没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。 + +两个读数都以实测墙钟时间作分母,因此都不可复现:同一个回放场景在本机连续两次跑出 69 与 70 tok/s,而一段 3 毫秒的回放流会读成 26333 tok/s。因此 Web aria golden 在既有的 `{{duration}}` 之外,把吞吐归一化为 `{{throughput}}`;页脚的装饰性分隔符也补上了两侧空格——没有它们,这些读数会连成一整串无障碍文本(`Ran for 13sTTFT 0.2s12 tok/s`),既让屏幕阅读器失去读数之间的边界,也让 `{{duration}}` 失去它赖以匹配的词边界。 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml new file mode 100644 index 0000000000..059f6d2a0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md +2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575 +2026-08-05-composer-context-meter-breakdown.zh.md: 441fd3013f2db721527838955b5ce227865615f1 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md new file mode 100644 index 0000000000..a757bcbc8b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md @@ -0,0 +1,31 @@ +# Agent Note: Composer context meter with heuristic composition breakdown + +Status: implemented + +English | [中文](2026-08-05-composer-context-meter-breakdown.zh.md) + +## Problem + +The Web chat's stats line showed context occupancy as one inline figure (`Context N% of X`) among its billing groups. That answers "how full" but not "what fills it": nothing showed how the window divides between the system prompt, tool schemas, and conversation, and the one-line row has no room for that detail. The available numbers also live in two vocabularies — the provider-exact billed prompt size from `contextPressure` versus the token-meter's fixed character heuristic — and no existing surface could present composition without conflating them. + +## Decision + +Three cooperating pieces, one per package boundary: + +`dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance. + +`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` and its positional surface fold into `src/surface-fold.ts` — both shared verbatim with the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure replays `foldSurfaceTokens` over a per-node `{seq, tokens}` list, so it equals `measure().surfaceTokens` at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. + +`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored `projectedTokens` instead, because the bare sample could not see a compaction — see [the meter's compaction blindness](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy. + +## Alternatives considered + +**Deriving composition client-side from the loaded window.** The window is a contiguous log suffix: the `request/header` events carrying the system prompt and tool schemas may sit outside it, and paging would silently change the figures. Only a durable host-side projection survives paging and compaction, which is why the data crosses the wire as a third projection rather than a chat-window fold. + +**Scaling the heuristic rows to sum to `pressureTokens`.** Forced reconciliation fabricates precision: pressure lags one request, includes provider envelope overhead the estimator never models, and would make the rows move when nothing in the composition changed. Showing the estimator's real vocabulary with an explicit `~` was chosen instead. + +**Finer categories (rules, skills, MCP tools) as in Claude Code's `/context`.** Not separable here: the harness folds those contributions into the system text and the tools list before the request header exists, so three categories are the honest resolution. + +## Consequences + +Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md new file mode 100644 index 0000000000..441fd3013f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md @@ -0,0 +1,31 @@ +# Agent Note: composer 上下文占用圆环与启发式组成明细 + +Status: implemented + +[English](2026-08-05-composer-context-meter-breakdown.md) | 中文 + +## 问题 + +Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N% of X`)挤在计费分组之间。它回答了「有多满」,却回答不了「被什么占满」:没有任何地方展示窗口在系统提示词、工具 schema 与对话之间如何分配,而单行统计行也容纳不下这种明细。可用的数字还分属两套口径——来自 `contextPressure` 的提供方精确计费 prompt 规模,与 token-meter 的固定字符启发式——没有任何既有界面能在不混淆两者的前提下展示组成。 + +## 决定 + +三个协作部分,每个包边界一个: + +`dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。 + +`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`、把位置表层折叠抽取到 `src/surface-fold.ts`(两者都与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字在逐节点 `{seq, tokens}` 列表上重放 `foldSurfaceTokens`,因此它在每个事件边界上按构造等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 + +`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题,与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——启发式占比只用于切分进度条的彩色分段与明细行,且每个启发式数字都标 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 `projectedTokens`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md)。)标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自己的字重;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。 + +## 备选方案 + +**在客户端从已加载窗口推导组成。** 窗口是日志的连续后缀:携带系统提示词与工具 schema 的 `request/header` 事件可能在窗口之外,翻页还会让数字悄悄变化。只有持久的 host 侧投影能在翻页与压缩后幸存,这正是数据以第三个投影而非聊天窗口 fold 的形式过线的原因。 + +**把启发式明细行按比例缩放到与 `pressureTokens` 相加一致。** 强行对账是在捏造精度:压力滞后一个请求,还包含估算器从不建模的提供方封装开销,会让明细行在组成毫无变化时也跟着变动。最终选择以显式 `~` 展示估算器的真实口径。 + +**更细的类别(rules、skills、MCP 工具,如 Claude Code 的 `/context`)。** 在这里不可分:harness 在请求标头存在之前就把这些贡献折入系统文本与工具列表,因此三个类别是诚实的分辨率。 + +## 后果 + +token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml new file mode 100644 index 0000000000..65d14e9aa4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +2026-08-05-context-form-vocabulary.md: 618c11b925208d09a62d10101656e3358e327ddb +2026-08-05-context-form-vocabulary.zh.md: 66278b261f23601461c96f08e87ea58766b2bfc2 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md new file mode 100644 index 0000000000..618c11b925 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -0,0 +1,73 @@ +# Agent Note: Producer-declared context forms + +Status: implemented + +English | [中文](2026-08-05-context-form-vocabulary.zh.md) + +## Problem + +Every logged non-user `user/message` rendered through one body: the whole message serialized as inline JSON. A reader opening a row met `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the provenance sat inside the same blob. + +Naming the producer in the header (the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md)) fixed *who added this*. It could not fix *what kind of thing was added*, because nothing in the log said so. Injected context is not one shape: a reconciled `AGENTS.md`, a catalog of available skills, a runtime policy snapshot, and a subagent's report are as different from each other as a terminal card is from a diff card, yet all four presented as the same wall of escaped JSON. + +The tool surface already solved this shape. `ToolCallView` has three cards, not one per tool, and a tool declares which card its call is. Context had no equivalent: no vocabulary of shapes, and no way for a producer to say which one it emits. + +## Decision + +`MessageSource` gains an optional producer-declared `form: ContextForm` — a small tagged vocabulary of information *shapes*, independent of `kind`: + +- `kind` answers **who produced this** and remains pure provenance. +- `form` answers **what shape of information it is**. Several producers may share one form, and one producer may emit more than one over a session. + +The vocabulary is semantic, never visual. A value states that the content is a file's instructions or a catalog of available items; colors, icons, ordering, and collapse defaults are the consumer's business and must not enter the union. It grows one value at a time, as producers gain the structured fields their form needs. This release declares two: + +**`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request. + +**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the `` block out of the prose. + +Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical. + +That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. One case does not self-heal: if that old-format catalog is the only one and the current view has no skills, the plugin sees no published catalog and emits no tombstone, so the model keeps a stale catalog nothing replaces. The pre-release stance ("backends reject old on-disk formats") permits it; it is recorded here rather than left to the optimistic path. + +**`snapshot`** — current state that a later snapshot from the same producer supersedes. The runtime-context snapshot, `time-context`, and `tmux-context` declare it. `renderContextSections()` exposes the assembly's named contributions, which `renderContextSnapshot()` already joined for the model, so the body attributes each part to the subsystem that produced it without re-splitting joined prose. The two single-contribution producers record one section each. The cleared runtime-context marker has no contributions left and declares no form. + +**`notice`** — a one-off account of something that just happened. `tool-tasks`, `tool-goal` wrap-up, `plan-mode` switches, and `repeat-tool-guard` reminders declare it with a `summary`, which rides the **collapsed** row: a notice is meant to be read without expanding at all. The summary is bounded where its inputs are caller text (a task's label and status detail have no length of their own). Goal state changes remain domain-owned `goal/change` events rather than model context, so they declare no form. + +**`relay`** — a message another agent addressed to this one. Both subagent-addressed sources declare it; the sender is shown as the opaque session id the source already records, because this client cannot resolve it to a title. + +**`recall`** — material lifted out of another session's log. `session-reference` declares it and needed no new field: its references already record the label, retained and omitted counts, and truncation flag, which the body shows first, because recalled context is bounded on the way in and a card that hid the omitted count would overstate what the model received. + +Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. + +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. + +Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. + +## Why not a presenter registry + +The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure function each tool implements. Context deliberately has no equivalent, because the input differs in ownership: a tool's `args` are generated by the **model** against a model-facing schema, so a translation step is unavoidable; a context `source` is constructed by the **producing plugin** itself, under no external constraint, and can simply record the facts a presentation needs. Adding a registry would have bought a translation nobody needs, at the cost of a host computation point, a wire field per context message, and a browser bundle for every producing package (the client purity gate forbids host packages from contributing components). + +## Alternatives considered + +**Map source kinds to renderers in the client.** Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md) removed for labels. + +**Reuse `kind` as the form.** One discriminant is simpler, and `workspace-instructions` is already 1:1 with its form. It breaks on the shared shapes: three producers emit runtime snapshots today, and folding them into one kind would erase their provenance. Two axes keep provenance exact while letting presentations be shared. + +**Let the client parse the model-facing prose.** The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text. + +**Render instructions as Markdown.** The body is a Markdown file and would read better rendered. The text also carries `` framing, which the Markdown renderer drops as raw HTML, so a Markdown body would silently hide part of what the model read. Deferred until the producer records per-file content structurally. + +## Testing + +- `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque. +- `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque. +- `packages/skill/tool-skill` pins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact. +- The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario. + +## Consequences + +- A reader can tell what was added without expanding, and reading it no longer means reading escaped JSON. +- The durable `MessageSource` now carries a semantic classification beside provenance. The boundary is load-bearing: facts and shape only, never presentation. A producer that wants a better card records better facts. +- Catalog identity no longer depends on the model-facing prose, deleting the text-slicing path that could mistake a reworded catalog for a changed one. +- Every shipped producer except the two hook bridges now declares a form. The bridges stay opaque by design: their content is whatever an external program printed, so no shape can be promised for it. Unknown kinds and unreadable records land there too. +- `ContextFormed` is discriminated by `form`, so a producer cannot declare a shape without the facts that shape is presented from — a `notice` without its summary, or a `snapshot` without its sections, fails to compile. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md new file mode 100644 index 0000000000..66278b261f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -0,0 +1,73 @@ +# Agent Note:由生产方声明的上下文形态 + +Status: implemented + +[English](2026-08-05-context-form-vocabulary.md) | 中文 + +## Problem + +每一条已记录的非用户 `user/message` 都通过同一个内容区渲染:把整条消息序列化成内联 JSON。读者展开一行,看到的是 `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`——转义把唯一值得读的东西(面向模型的散文)压成了一行,而来源信息又和它挤在同一坨里。 + +在标题栏写出生产者([来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md))解决了「这是谁加的」。它解决不了「加进来的是什么东西」,因为日志里根本没有这句话。注入上下文不是一种形状:对账后的 `AGENTS.md`、可用 skill 的目录、运行时策略快照、子 agent 的汇报,彼此之间的差别不亚于终端卡片与 diff 卡片,然而这四者呈现出来是同一堵转义 JSON 的墙。 + +工具面早就解决过同一个形状问题。`ToolCallView` 只有三种卡片,而不是每个工具一种,由工具自己声明本次调用属于哪一种。上下文没有对应物:既没有形状词汇表,生产方也无从声明自己发出的是哪一种。 + +## Decision + +`MessageSource` 新增一个可选、由生产方声明的 `form: ContextForm`——一份关于信息**形状**的小型 tagged 词汇表,与 `kind` 相互独立: + +- `kind` 回答**由谁产生**,保持纯粹的溯源语义。 +- `form` 回答**这是何种形态的信息**。多个生产方可以共用一种形态,一个生产方在一次会话中也可以发出多种。 + +该词汇表是语义的,绝不涉及视觉。取值只陈述「内容是某个文件的指令」或「是一份可用项目录」;颜色、图标、排序、默认折叠状态归消费方管,不得进入这个联合类型。它随生产方补齐各自形态所需的结构化字段而逐个增长。本次声明两个: + +**`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。 + +**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `` 块。 + +条目记录的是**未转义**的发布事实。伪 XML 转义属于 `` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。 + +这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。有一种情形不会自愈:当那份旧格式目录是唯一的一份、且当前视图没有任何 skill 时,插件看不到已发布目录,也就不会发出 tombstone,模型手里会留着一份无人替换的过期目录。发布前阶段的姿态(「后端拒绝旧的磁盘格式」)允许这一点;此处如实记录,而不是只写乐观路径。 + +**`snapshot`**——会被同一生产方后续快照取代的当前状态。运行时快照、`time-context`、`tmux-context` 声明它。`renderContextSections()` 暴露出装配时的具名贡献——`renderContextSnapshot()` 本来就是把它们拼给模型的——因此内容区能把每一段归属到产生它的子系统,而不必去切分已经拼好的散文。两个单贡献生产方各记录一段。运行时快照的「已清空」标记没有任何贡献可归属,因此不声明形态。 + +**`notice`**——刚刚发生了什么的一次性说明。`tool-tasks`、`tool-goal` 收尾、`plan-mode` 切换与 `repeat-tool-guard` 提醒都带 `summary` 声明它,而该摘要出现在**折叠态**行上:notice 的全部意义就是不展开也能读完。摘要在其输入是调用方文本时自行封顶(任务的 label 与状态 detail 本身没有长度约束)。Goal 状态变更仍是由领域层持有的 `goal/change` 事件,而非模型上下文,因此不声明 form。 + +**`relay`**——另一个 agent 发给本 agent 的消息。两个子 agent 定向来源都声明它;发送方以来源已记录的不透明会话 id 呈现,因为本客户端无法把它解析成标题。 + +**`recall`**——从另一个会话日志搬来的材料。`session-reference` 声明它,且不需要新增字段:其 references 已经记录了标题、保留与省略条数、截断标记,内容区把这些放在最前面——召回上下文在进入时是有界的,隐藏省略条数的卡片会夸大模型实际收到的内容。 + +两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 + +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 + +其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 + +## 为什么不做 presenter 注册表 + +工具接缝把它的词汇表与 `presentCall(args)` 配对,那是每个工具在 host 侧实现的纯函数。上下文刻意不设对应物,因为输入的归属不同:工具的 `args` 由**模型**按面向模型的 schema 生成,翻译步骤无法回避;而上下文的 `source` 由**生产方插件**自己构造,不受任何外部约束,完全可以直接记录呈现所需的事实。加一层注册表买到的是一次没人需要的翻译,代价却是一个 host 计算点、每条上下文消息一个 wire 字段、以及每个生产方包都要出浏览器 bundle(客户端纯度门禁禁止 host 包贡献组件)。 + +## Alternatives considered + +**在客户端把来源 kind 映射到渲染器。** 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md)刚为名称去掉的那种耦合。 + +**复用 `kind` 充当形态。** 单一判别式更简单,`workspace-instructions` 本来也与它的形态一一对应。但它在共享形状上就崩了:今天有三个生产方发出运行时快照,把它们并成一个 kind 会抹掉各自的溯源。两根轴既保住溯源的精确,又让呈现可以共享。 + +**让客户端解析面向模型的散文。** 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到 prompt 措辞上,于是每改一次文案就静默碎掉一张卡——这也正是目录身份从文本上迁走的原因。 + +**把 instructions 渲染成 Markdown。** 正文本来就是 Markdown 文件,渲染出来更好读。但文本同时携带 `` 包装,Markdown 渲染器会把它当原始 HTML 丢弃,于是 Markdown 内容区会悄悄隐藏模型读到的一部分。推迟到生产方按文件结构化记录内容之后再做。 + +## Testing + +- `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。 +- `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。 +- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察。 +- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。 + +## Consequences + +- 读者不展开就能知道加进来的是什么,展开之后读到的也不再是转义 JSON。 +- 持久 `MessageSource` 现在在溯源之外还承载一个语义分类。这条边界是承重的:只放事实与形状,绝不放呈现。想要更好卡片的生产方应当记录更好的事实。 +- 目录身份不再依赖面向模型的散文,删掉了那条可能把「改了措辞」误判为「改了内容」的文本切分路径。 +- 除两个 hook 桥接外,每个已发布的生产方现在都声明了形态。桥接按设计保持 opaque:其内容是外部程序打印出来的任意文本,无法承诺任何形状。未知 kind 与不可读记录同样落在这里。 +- `ContextFormed` 按 `form` 判别,因此生产方无法在缺少该形态所需事实的情况下声明它——没有 summary 的 `notice`、没有 sections 的 `snapshot`,都会编译失败。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml new file mode 100644 index 0000000000..dcb3a9406b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 +2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md new file mode 100644 index 0000000000..6bbdb0e6bc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -0,0 +1,32 @@ +# Agent Note: pwsh UI presentation matches bash + +Status: implemented + +English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) + +## Problem + +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. + +## Decision + +`dsh-tool-pwsh`'s `presentResult` now mirrors `dsh-tool-bash`'s call-for-call: a completed foreground result is a `terminal` card whose output body is the marker-free rendered text and whose exit-status pill is the parsed `exitCode`/`signal`; background acknowledgements and `isError` results stay generic `console`-fenced cards; non-single-text-block results stay untouched (`undefined`). + +The parse is shared, not duplicated: `parseExitStatus`/`ParsedExitStatus` moved from `dsh-tool-bash`'s private render module into the `@deepseek-ai/dsh-bash` seam package (exported from its index), and `dsh-tool-bash`'s `render.ts` re-exports it so its source-plane consumers keep one import root. Both tools' renderers emit the same `[exit code: N]` / `[killed by signal: X]` markers, so one seam-owned inverse can never drift between the twins — the same "shared, not duplicated" shape the [bash-env extraction](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) used for the `DSH_*` registry. + +The Web UI needs no per-tool code for the card itself: the client's terminal-card bridge maps any `card: 'terminal'` result view (`terminal-card-model` in `dsh-client-ui-conversation`), so the pwsh presenter change flows through the same rendering path bash already has. The collapsed tool row does get one client classification entry: `classifyTool('pwsh')` now yields the shell-family row (`bash` variant, its own `Pwsh` title) instead of the generic `others` "Tool call" row. A keyless browser lane (`apps/web/tests/pwsh-terminal.e2e.ts`) seeds a session whose pwsh call/result is presented by the real tool on replay — the api-proxy recomputes views from logged args/result content — and pins the terminal card golden, including the exit pill and the run-state dot. + +## Alternatives considered + +**Import `parseExitStatus` from `@deepseek-ai/dsh-tool-bash/src/render.ts`.** Rejected: workspace imports stay external in the built bundles, so `tool-pwsh` would gain a hard runtime dependency on `tool-bash` in every consumer closure (including compositions that deliberately mount the pwsh twin without bash), and a sibling tool depending on its twin for one function inverts the package relationship. The seam move keeps the shared contract on a package both tools already depend on. + +**A new dedicated presentation package (e.g. `@deepseek-ai/dsh-shell-present`).** Rejected: a new package costs manifests, module-graph/catalog regeneration, and README surface for a single pure function; `@deepseek-ai/dsh-bash` is already in both tools' closures and already owns the `BashRunResult` facts the parse reconstructs. + +**Duplicate the parse into `tool-pwsh`'s render module (a third twin).** Rejected: the parity review's core finding was that copied text contracts drift without a shared implementation; the parse and the marker emission must co-evolve in one place, and the parse is exactly the contract the UI pill depends on. + +## Consequences + +- A Windows composition using `dsh-tool-pwsh` now shows its shell calls exactly as bash calls look in the Web UI: cwd-headed terminal card, raw output, exit-status pill, run-state dot, and the red failure treatment on non-zero exits. +- `parseExitStatus` becomes public seam surface on `@deepseek-ai/dsh-bash`; `dsh-tool-bash/src/render.ts` keeps re-exporting it, so no bash-tool consumer changes. +- The roadmap's stage 2 shrinks: the TUI is removed (EOL), and the terminal-card counterpart now ships on the Web surface. The Windows default composition (stage 1) remains the outstanding stage. +- Verification: `dsh-bash` owns the parse edge cases under the per-file coverage gate; `tool-pwsh`'s presenter suite mirrors `tool-bash`'s (clean/non-zero/signal/timeout round-trip, marker-like output, background/error generics, multi-block fallback); the client row-model suite pins the `Pwsh` shell-family row; the web `pwsh-terminal` lane is the assembled keyless scenario. diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md new file mode 100644 index 0000000000..75f3a3ddec --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -0,0 +1,32 @@ +# Agent Note: pwsh UI presentation matches bash + +Status: implemented + +[English](2026-08-05-pwsh-ui-bash-parity.md) | 中文 + +## Problem + +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 + +## Decision + +`dsh-tool-pwsh` 的 `presentResult` 现在逐调用镜像 `dsh-tool-bash`:完成的前台结果是 `terminal` 卡,输出正文为去 marker 的渲染文本,退出状态 pill 为解析出的 `exitCode`/`signal`;后台 ack 与 `isError` 结果保持通用 `console` 围栏卡片;非单一文本块结果保持不变(`undefined`)。 + +解析是共享而非复制:`parseExitStatus`/`ParsedExitStatus` 从 `dsh-tool-bash` 的私有 render 模块迁入 `@deepseek-ai/dsh-bash` seam 包(由其 index 导出),`dsh-tool-bash` 的 `render.ts` 再导出它,使源平面消费方保持单一导入根。两个工具的渲染器发出相同的 `[exit code: N]` / `[killed by signal: X]` marker,因此一个由 seam 拥有的逆解析永远不会在孪生之间漂移——与 [bash-env 抽取](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 处理 `DSH_*` 注册表时相同的 "共享而非复制" 形态。 + +Web UI 的卡片本身不需要任何按工具编写的代码:客户端的 terminal 卡桥接(`dsh-client-ui-conversation` 的 `terminal-card-model`)映射任意 `card: 'terminal'` 结果视图,因此 pwsh presenter 变更直接流经 bash 已有的同一渲染路径。折叠的工具行有一处客户端分类条目:`classifyTool('pwsh')` 现在归入 shell 家族行(`bash` variant,自有 `Pwsh` 标题),而非通用的 `others` "Tool call" 行。一条 keyless 浏览器通道(`apps/web/tests/pwsh-terminal.e2e.ts`)播种一个会话,其 pwsh 调用/结果在回放时由真实工具呈现(api-proxy 从已记录的 args/result 内容重新计算视图),并钉住 terminal 卡 golden,包括退出 pill 与运行状态点。 + +## Alternatives considered + +**从 `@deepseek-ai/dsh-tool-bash/src/render.ts` 导入 `parseExitStatus`。** 否决:workspace 导入在构建产物中保持外部引用,因此 `tool-pwsh` 会在每个消费方闭包中新增对 `tool-bash` 的硬运行时依赖(包括刻意只挂 pwsh 孪生、不挂 bash 的组合),且兄弟工具为其单个函数依赖孪生会颠倒包间关系。seam 迁移把共享契约放在两个工具本就依赖的包上。 + +**新建专用呈现包(如 `@deepseek-ai/dsh-shell-present`)。** 否决:为一个纯函数新建包要付出 manifest、module-graph/目录再生成与 README 面的成本;`@deepseek-ai/dsh-bash` 已在两个工具的闭包中,且已拥有该解析重建的 `BashRunResult` 事实。 + +**把解析复制进 `tool-pwsh` 的 render 模块(第三个孪生)。** 否决:parity 评审的核心发现正是"复制的文本契约缺少共享实现就会漂移";解析与 marker 发出必须在同一处共同演化,而解析恰恰是 UI pill 依赖的契约。 + +## Consequences + +- 使用 `dsh-tool-pwsh` 的 Windows 组合现在在 Web UI 中显示的 shell 调用与 bash 调用完全一致:cwd 头的 terminal 卡、原始输出、退出状态 pill、运行状态点,以及非零退出时的红色失败处理。 +- `parseExitStatus` 成为 `@deepseek-ai/dsh-bash` 的公开 seam 表面;`dsh-tool-bash/src/render.ts` 继续再导出它,bash 工具消费方零改动。 +- 路线图阶段 2 收窄:TUI 已移除(EOL),terminal 卡对应物现已在 Web 表面交付。Windows 默认组合(阶段 1)仍是未完成的阶段。 +- 验证:`dsh-bash` 在逐文件覆盖率门禁下拥有解析边界用例;`tool-pwsh` 的 presenter 套件镜像 `tool-bash` 的(干净/非零/信号/超时往返、marker 状输出、后台/错误 generic、多块回退);客户端行模型套件钉住 `Pwsh` shell 家族行;web `pwsh-terminal` 通道是组装后的 keyless 场景。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index 3c41db871b..e8cdb21a40 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.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/process/2026-06-11-quality-gates.md 2026-06-11-quality-gates.md: 60db7ba5cfa8184c0fcce764aa027f32a9b721ab -2026-06-11-quality-gates.zh.md: a5ac7cd831255d479f7a1d75586785e546877fba +2026-06-11-quality-gates.zh.md: 10585977820ba8e4f4bc6e251d484dc657d0558c diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index a5ac7cd831..1058597782 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -14,16 +14,16 @@ Status: implemented 每条可机械检查的 AGENTS.md 承诺都有一个以非零状态退出的命令。CI 执行完整集合,而 Git 钩子将延迟预算留给可低成本发现的本地缺陷: -- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而包(package)/vendor 代码保持在各自 project-reference 边界之后。 +- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而包/vendor 代码保持在各自 project-reference 边界之后。 - [Oxlint](2026-07-29-oxlint-linter.md) 配合类型感知的 TypeScript 规则以及 @stylistic 和 SonarJS 兼容插件,强制执行统一代码风格和文件内重复逻辑检查;vendor 代码排除在外。 - jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 +- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest(元数据清单);pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 -- 约定不会因 agent 更替而失效;可低成本发现的 commit/push 缺陷在本地失败,其余完整规则违规在 CI 中失败。 +- 约定不会因 agent 更替而失效;可低成本发现的 commit/push 缺陷会在本地触发失败,其余违规会在 CI 的完整检查中触发失败。 - 门禁本身也是需要维护的代码;配置变更与其他变更一样需要评审。 - 100% 覆盖率的压力可能催生无断言的测试——变异测试是计划中的对策(见[变异测试提案](../../proposed/testing/2026-06-11-mutation-testing.md))。 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index 6703e7bc9d..dbed654d18 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.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/process/2026-06-16-pnpm-over-yarn.md 2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1 -2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3 +2026-06-16-pnpm-over-yarn.zh.md: 7560b748654cf79409e16b6c2b1c701b367f01d4 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index eb13890b7e..7560b74865 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -6,18 +6,18 @@ Status: implemented ## 问题 -本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 +本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 -切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 +切换成本目前处于最低点。本仓库尚无任何包发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 ## 决策 采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同): - **Workspaces** 从 `package.json` 的 `workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——同样的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 -- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幻影依赖(引用未声明的传递依赖)大声失败,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 +- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 - **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 -- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查 privacy。 +- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查是否为私有。 - 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。 ## 曾考虑的替代方案 @@ -30,14 +30,14 @@ Status: implemented 约束检查失去了 Yarn 的自动**修复**能力(`workspace.set()` 能原地改写 manifest);tsx 脚本仅做检查,不通过时以非零退出码和消息退出。这是可接受的:CI 从未运行过 `--fix`,且需要手动编辑的情况很少。贡献者现在为 pnpm 而非 Yarn 运行 `corepack enable`;`pnpm exec lefthook install` 取代 `yarn lefthook install`(`postinstall` 钩子仍会运行 `lefthook install`)。 -性能(迁移时在开发 NFS 文件系统上测量;单次运行样本,方差大——仅供方向性参考,非基准测试套件): +性能(迁移时在开发 NFS 文件系统上测量;运行次数为个位数的样本,方差大——仅供方向性参考,非基准测试套件): | 场景 | Yarn 4 | pnpm 11 | |---|---|---| | 冷启动(空缓存/store,无 `node_modules`) | ~14 s | ~16 s | | 热重链接(缓存/store 已热,`node_modules` 已删除) | ~12–14 s | ~15–22 s | -| 冻结,`node_modules` 存在(无操作重验证) | ~2–8 s | ~0.5–7 s | +| 冻结安装,`node_modules` 存在(无操作重验证) | ~2–8 s | ~0.5–7 s | 在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装中胜出,尤其在多个检出之间的**磁盘占用**方面优势明显(一个全局 store 通过硬链接接入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者经常为本仓库保持约 10 个或更多 worktree)。该去重优势在上述迁移时数据中**未能**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上则适用。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幻影依赖安全性和跨检出磁盘去重,而非原始安装时间的胜出。 -所有质量门禁(constraints、类型检查、lint、doc-sync、达到 100% 的 test:coverage、构建、knip、publint 以及已构建应用的冒烟测试)均在 pnpm 下通过,证明更换 linker 没有引入幽灵依赖故障。 +所有质量门禁(constraints、类型检查、lint、doc-sync、达到 100% 的 test:coverage、构建、knip、publint 以及已构建应用的冒烟测试)均在 pnpm 下通过,证明更换链接器没有引入幻影依赖故障。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 4319bcee3e..fe42a92f75 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md -2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf -2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484 +2026-06-17-ts-build-config.md: ec4c6a4aeb1074a69d45b2cf4b4c733b410ccceb +2026-06-17-ts-build-config.zh.md: 8115bb2557eea967d57eb6693e2bb288188792a8 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 20dd3d8d0a..ec4c6a4aeb 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -32,7 +32,7 @@ In-package relative imports use explicit `.ts` specifiers. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. +- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. Publication keeps `.d.ts`; packages whose runtime exports explicitly point into the emitted tree also keep its `.js` files. `.js.map` and `.d.ts.map` remain in the local build tree. - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. - Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. @@ -77,9 +77,9 @@ Build responsibilities are clearer: - Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. - The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. + - `lib/types/*.d.ts` is the publish declaration output; `.d.ts.map` remains only as a local compilation artifact. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. + - `lib/types/*.js` is normally only a bundler input. It is published only when an explicit runtime export points into the emitted tree. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 196ed3f292..8115bb2557 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -32,7 +32,7 @@ Status: implemented `pnpm run build` 是两阶段构建: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 +- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts`;如果包的运行时 export 显式指向该输出树,也会保留其中的 `.js` 文件。`.js.map` 和 `.d.ts.map` 留在本地构建树中。 - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 - 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 @@ -77,9 +77,9 @@ tsx scripts/clean.ts - `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 - `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 - - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。 + - `lib/types/*.d.ts` 是发布用的声明输出;`.d.ts.map` 只作为本地编译产物保留。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - - `lib/types/*.js` 仅作为打包器输入,禁止用作运行时入口或公开导入目标。 + - `lib/types/*.js` 通常仅作为打包器输入。只有显式运行时 export 指向该输出树时,才会发布这些文件。 - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 - `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 - `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml index 0e6f7fde7e..bc6582cb20 100644 --- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md 2026-06-18-markdown-cross-link-lint.md: b8b1337e9d758da6a4cc0bb46a6b37906357f877 -2026-06-18-markdown-cross-link-lint.zh.md: 823af80950127a0bf0b76da7769611d0d3a6c09b +2026-06-18-markdown-cross-link-lint.zh.md: 9b627ebb17a0567424ca0caaeac8edd9b36917f2 diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md index 823af80950..9b627ebb17 100644 --- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md +++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。[Doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化(无法编译的代码块、陈旧的事件分类表),[verify-md-wrap](../../archived/process/2026-06-11-doc-sync-enforcement.md) 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。 +本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。[doc-sync(文档同步门禁)强制执行](../../archived/process/2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移的检查自动化(无法编译的代码块、陈旧的事件分类表),[verify-md-wrap](../../archived/process/2026-06-11-doc-sync-enforcement.md) 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。 -引入这道门禁的直接动因是 Agent Note(agent 决策记录)目录树重组:将 `docs/adr/` 与 `.agents/notes/` 统一到同一个 `.agents/notes/` 下,并设置 `proposed/`、`implemented/`、`rejected/` 子目录,需要手工重命名约 40 条文档间链接。只要有一处路径输入错误,就会在没有任何检查拦截的情况下交付断链。 +引入这道门禁的直接动因是 Agent Note 目录树重组:将 `docs/adr/` 与 `.agents/notes/` 统一到同一个 `.agents/notes/` 下,并设置 `proposed/`、`implemented/`、`rejected/` 子目录,需要手工重命名约 40 条文档间链接。只要有一处路径输入错误,就会在没有任何检查拦截的情况下交付断链。 ## 决策 @@ -18,9 +18,9 @@ Status: implemented - 仅当目标是**相对路径**时才检查。跳过带协议的 URL(`https:`、`mailto:` 等)、协议相对路径(`//host`)、根绝对路径(`/path`,在检出目录中没有稳定基准)以及纯页内锚点(`#section`)。剥除 `#fragment`/`?query`,相对于链接所在文件的目录解析路径,并断言目标在磁盘上存在。 - 只报告、不改写;发现第一条死链即以非零状态退出。 -检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 `.agents/skills/` 下仓库自有的 agent-skill(技能)Markdown(这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`。系统按真实路径去重(`CLAUDE.md` symlink 会解析到 AGENTS.md 文件)。该检查接入 `doc-sync`,因此相关文档变更与 CI 执行同一套断链检查。 +检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 `.agents/skills/` 下仓库自有的 agent skill(技能)Markdown(这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`。系统按真实路径去重(`CLAUDE.md` symlink 会解析到 AGENTS.md 文件)。该检查接入 `doc-sync`,因此相关文档变更与 CI 执行同一套断链检查。 -本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件可解析;片段被剥除)。 +本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件路径可解析;片段被剥除)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml index bbf8f64dbc..6e708d4e4a 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-20-agent-note-classification.md 2026-06-20-agent-note-classification.md: edb65a772c81b818bf3811c9f3f64ed1a6497647 -2026-06-20-agent-note-classification.zh.md: eff333b52309fbfc7706fbf15a26b07c761f5b0e +2026-06-20-agent-note-classification.zh.md: e2a21fc4ca6579720ed25aae09ef439f63a2de8c diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md index eff333b523..e2a21fc4ca 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -仅按生命周期组织的 Agent Note(agent 决策记录)目录树(`proposed/` / `implemented/` / `rejected/`)无法记录每个文件包含哪一*类*决策。读者浏览某个生命周期时,如果不逐一打开文件,就无法区分新功能、移除项或工具策略变更。 +仅按生命周期组织的 Agent Note 目录树(`proposed/` / `implemented/` / `rejected/`)无法记录每个文件包含哪一*类*决策。读者浏览某个生命周期时,如果不逐一打开文件,就无法区分新功能、移除项或工具策略变更。 本仓库一贯的倾向是[机械质量门禁优于行文规范](2026-06-11-quality-gates.md):不被机器检查的约定终将腐烂。因此这里的分类方案必须可强制执行,而非靠自觉的文件头。 @@ -19,9 +19,9 @@ Status: implemented | 类别 | 涵盖范围 | |---|---| | `feature` | 面向用户或模型的新功能。 | -| `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | -| `simplification` | 移除代码、行为或对外表面积,不引入新功能。 | -| `architecture` | 关于**交付源码**的结构性决策——包(package)之间的关系、运行时词汇。 | +| `bug-fix` | 修正缺陷或填补事故复盘(postmortem)暴露的空白。 | +| `simplification` | 移除代码、行为或对外接口范围,不引入新功能。 | +| `architecture` | 关于**交付源码**的结构性决策——包之间的关系、运行时词汇。 | | `process` | **围绕**代码的工具、策略或工作流,而非运行时行为。 | | `testing` | 测试基础设施与策略。 | @@ -31,14 +31,14 @@ Status: implemented 两者都是 `doc-sync`(文档同步门禁)的成员,风格与 `verify-md-wrap` 一致(tsx ESM,只校验不生成,首个违规即以非零退出码退出): -- **`scripts/verify-agent-note-classification.ts`**:定义封闭的生命周期与类别集合。它断言生命周期文件夹下的每个文件都位于规范集合中的类别文件夹内(生命周期根目录下散落的 `.md` 或未知类别文件夹都会失败),并拒绝集中式 `INDEX.md`。规范集合位于 `scripts/agent-note-tree.ts` 中,[README](../../README.md)则以行文记录每个类别。 -- **`scripts/verify-doc-refs.ts`**:检查引用文档的源码注释。Agent Note 路径不仅出现在 Markdown 中,也出现在 TypeScript 文档注释中(例如以仓库根为起点的 `.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 看不到这些引用,因此目录重组可能静默留下失效引用。该门禁扫描 `packages/**` 与 `examples/**` 下仓库自有的 `.ts` 文件(排除已构建的 `lib/` 与 `vendor/`),查找 `docs/….md` 和 `.agents/notes/….md` token,解析每个以仓库根为起点的路径并断言其存在。它要求使用 `.md` 扩展名,因此不处理无扩展名的行文。 +- **`scripts/verify-agent-note-classification.ts`**:定义封闭的生命周期与类别集合。它断言生命周期文件夹下的每个文件都位于规范集合中的类别文件夹内(生命周期根目录下散落的 `.md` 或未知类别文件夹都会失败),并拒绝集中式 `INDEX.md`。规范集合位于 `scripts/agent-note-tree.ts` 中,[README](../../README.md) 则以行文记录每个类别。 +- **`scripts/verify-doc-refs.ts`**:检查引用文档的源码注释。Agent Note 路径不仅出现在 Markdown 中,也出现在 TypeScript 文档注释中(例如以仓库根为起点的 `.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 看不到这些引用,因此目录重组可能静默留下失效引用。该门禁扫描 `packages/**` 与 `examples/**` 下仓库自有的 `.ts` 文件(排除已构建的 `lib/` 与 `vendor/`),查找 `docs/….md` 和 `.agents/notes/….md` token,解析每个以仓库根为起点的路径并断言其存在。它要求使用 `.md` 扩展名,因此会忽略行文中不带扩展名的引用。 ## 曾考虑的替代方案 -- **在每个文件中添加 `Classification:` 行文行**(紧邻 `Status:`),由门禁解析。可行,但它将路径已能承载的事实重复到文件中,且行内容可能与所在文件夹不一致。路径编码使标签与其存储合二为一,没有需要保持同步的东西。 +- **在每个文件中添加 `Classification:` 文本行**(紧邻 `Status:`),由门禁解析。可行,但它将路径已能承载的事实重复到文件中,且行内容可能与所在文件夹不一致。路径编码使标签与其存储合二为一,没有需要保持同步的东西。 - **设立 `refactor` 类别。** 与 `simplification` 几乎完全重叠;唯一有人试图用来区分的标准是「可观察行为是否改变?」,而 `simplification` 已经编码了这一点(它不改变)。一个类别即可,无需两个。 -- **生成或手工维护的语料索引。** 不予采纳:生命周期/类别目录树才是权威结构;集中式清单会制造合并热点,却没有提供目录树导航或仓库搜索无法实现的发现能力。 +- **生成或手工维护的文档集索引。** 不予采纳:生命周期/类别目录树才是权威结构;集中式清单会制造合并热点,却没有提供目录树导航或仓库搜索无法实现的发现能力。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 0149872c3c..f58f595671 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md 2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a -2026-06-20-core-data-structures-catalog.zh.md: 4ace2b8c8a6b08e7721c1df8003ccfbdb128daf1 +2026-06-20-core-data-structures-catalog.zh.md: 0545235f96341a638c43805de1b47a400d69e618 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 4ace2b8c8a..0545235f96 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -6,22 +6,22 @@ Status: implemented ## 问题 -试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、会话/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 +试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、会话/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 -因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包(package)边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note(agent 决策记录)记下了这两项决策。与它配套的[生成式 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 +因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note 记下了这两项决策。与它配套的[生成的 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 ## 决策 -新增的 `docs/core-data-structures/` 目录对这些词汇编目,并配有新的 `verify-type-equiv` 文档同步门禁,使每个粘贴的类型声明及其 JSDoc 与源码保持同步。 +新增的 `docs/core-data-structures/` 目录对这些词汇编目,并配有新的 `verify-type-equiv` doc-sync(文档同步门禁),使每个粘贴的类型声明及其 JSDoc 与源码保持同步。 -### 何为"核心"——主干与 seam 的分界线 +### 何为「核心」——主干与 seam 的分界线 -范围界定并非自上而下拍定,而是将候选定义逐一对照具体的边界类型反复测试,直到一条规则在所有案例中都成立。决定性的测试是 `BashExecRequest`/`BashExecSpec`/`BashRunResult`:bash 是一个能力 *seam*,不属于 agent loop(智能体循环)主干;如果这些算"核心",那么"核心"就意味着*所有跨包词汇*,目录沦为平铺罗列;如果不算,"核心"就意味着*中央主干*,bash 词汇归入子页面。后者胜出,由此确定了整体结构:一个**分层文件夹**,而非一份平铺文档。 +范围界定并非自上而下拍定,而是将候选定义逐一对照具体的边界类型反复测试,直到一条规则在所有案例中都成立。决定性的测试是 `BashExecRequest`/`BashExecSpec`/`BashRunResult`:bash 是一个能力 *seam*,不属于 agent loop(智能体循环)主干;如果这些算「核心」,那么「核心」就意味着*所有跨包词汇*,目录沦为平铺罗列;如果不算,「核心」就意味着*中央主干*,bash 词汇归入子页面。后者胜出,由此确定了整体结构:一个**分层文件夹**,而非一份平铺文档。 确定其余案例的规则是:***你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子页面细节。*** 逐一验证如下: - 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都会持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面对某条流水线时编写的唯一标志性类型(`ToolDefinition`)。 -- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,撰写重要性压过了严格的"流经主干"规则。但它的类型推导机制——`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue` 与 `InferArgs`——是子页面细节。这就是主干与 seam 分界线的精确表述。 +- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,编写层面的重要性压过了严格的「流经主干」规则。但它的类型推导机制——`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue` 与 `InferArgs`——是子页面细节。这就是主干与 seam 分界线的精确表述。 - `ToolSchema` 是核心(它是流经每个步骤的模型请求 `GenerateOptions` 的一个字段),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 - 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 @@ -31,18 +31,18 @@ Status: implemented 持久性要求很具体:文档展示当前类型声明与原始 JSDoc 的**逐字**内容(让读者看到真实形状和源码契约,而非复述),**并且**以机械方式保证其与源码匹配。仓库已经会编译 ` ```ts ` 围栏块(`doc-typecheck`),但真正接受类型检查的块需要导入噪音,而且只能证明*可赋值性*——字段改名或 JSDoc 变化仍可能通过。因此: -- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。 +- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载类的源码等价环境声明投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。 - 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。 -- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest 条目之间强制执行 **1:1 对应**,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 `.zh.md` 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。 +- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest(元数据清单)条目之间强制执行 **1:1 对应**,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 `.zh.md` 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。 - 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。 ### 维护是作者的职责,门禁作为兜底 -`verify-type-equiv` 能捕获已记录类型的*粘贴漂移*,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 `dsh-code-review` skill(技能)已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新增表面。 +`verify-type-equiv` 能捕获已记录类型的*粘贴漂移*,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 `dsh-code-review` skill(技能)已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新增类型。 ## 曾考虑的替代方案 -- **平铺罗列所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算"核心",目录对谁都没帮助;分层的主干与 seam 结构胜出。 +- **平铺罗列所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算「核心」,目录对谁都没帮助;分层的主干与 seam 结构胜出。 - **用编译式 `_Check` 可赋值性断言**代替源码匹配:否决。可赋值性不会保留名称或 JSDoc;同类型字段改名或契约注释变化仍会通过。 - **来源信息作为行文中的指令注释**:否决,改用集中 manifest;其强制的 1:1 对应确保一个块永远不会被静默漏检,一条条目也永远不会腐烂。 @@ -50,11 +50,11 @@ Status: implemented 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及会话/持久化拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是清单点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为孤儿。本 Agent Note 将这条失败关闭扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成式 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 -- 这些词汇现在有一个**无法悄然漂移**的唯一归属:源码中的字段或公共类成员发生变化后,`doc-sync` 和 CI 中的 `verify-type-equiv` 会持续失败,直至粘贴内容刷新。Cordis 服务方法仍由生成式服务目录负责,而不会在此重复。 +- 这些词汇现在有一个**无法悄然漂移**的唯一归属:源码中的字段或公共类成员发生变化后,`doc-sync` 和 CI 中的 `verify-type-equiv` 会持续失败,直至粘贴内容刷新。Cordis 服务方法仍由生成的服务目录负责,而不会在此重复。 - 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性的:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 - `ts type-equiv` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 - 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的*新*类型),由 `dsh-code-review` 检查清单兜底。 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml index 5faf01450d..00064dc95b 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md 2026-06-20-generated-cordis-catalog.md: 5005e50a2e23c8286a8057dc57f365554bde5056 -2026-06-20-generated-cordis-catalog.zh.md: 0f8f20673b01ef1218a7d2dfa47c189862803775 +2026-06-20-generated-cordis-catalog.zh.md: 384e00d23aafeec7c7bed9bf572a628f77150993 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md index 0f8f20673b..384e00d23a 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 生成式 Cordis 事件与服务目录 +# Agent Note: 生成的 Cordis 事件与服务目录 Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -插件作者需要两个参考面,而此前没有任何单一文档能提供:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.` **服务**(含精确接口)。相关信息虽然存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类表格还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 +插件作者需要两类此前没有任何单一文档能同时提供的参考信息:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.` **服务**(含精确接口)。相关信息虽然存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类体系*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类体系表格还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 -这是对[核心数据结构目录](../../../../docs/core-data-structures/core.md)([其 Agent Note(agent 决策记录)](2026-06-20-core-data-structures-catalog.md))在接线维度上的补充:前者对循环传递的*数据结构*编目(经验证的手工粘贴),本文则对传递它们的*事件和服务*编目。 +这是对[核心数据结构目录](../../../../docs/core-data-structures/core.md)([其 Agent Note](2026-06-20-core-data-structures-catalog.md))在接线维度上的补充:前者对循环传递的*数据结构*编目(经验证的手工粘贴),本文则对传递它们的*事件和服务*编目。 ## 决策 @@ -16,26 +16,26 @@ Status: implemented `scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,根据声明和源码 JSDoc 分别生成事件与服务参考。事件包含分派模式及其原始成员 JSDoc;服务包含公共签名及各方法的原始 JSDoc。确定性的 `--write` 和 `--check` 模式使两个页面成为生成产物,并由 `doc-sync` 强制检查新鲜度。 -纯生成在此处是正确的,因为代码库足够规范,AST 就是全部事实:每个事件/服务名称都是字符串字面量,可以往返映射到静态声明——不存在动态命名的事件,也不存在仅运行时的服务。因此生成的文档不可能出错,且从结构上消除了未记录事件的缺口(生成器枚举源码,而非校验手写子集)。 +完全通过生成来构建目录在此处是正确的,因为代码库足够规范,AST 包含全部事实:每个事件/服务名称都是字符串字面量,可以往返映射到静态声明——不存在动态命名的事件,也不存在仅运行时的服务。因此生成的文档不可能出错,且从结构上消除了未记录事件的缺口(生成器枚举源码,而非校验手写子集)。 具体选择: - **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带一个显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有决定性时——尾部参数为 `next: () => …` 在结构上即为 waterfall(瀑布式事件)——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区别在结构上不可见(`session/flush` 返回 `Promise | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。编写规则见 [AGENTS.md](../../../../AGENTS.md)。 -- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而非遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 -- **指向数据结构目录的交叉链接。** 签名中由仓库拥有的每个类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition`……)都会通过人工维护的映射链接到其主要核心数据结构页面。AST 遍历采用失败关闭策略:每个参数、泛型约束/默认值和返回类型引用都必须已映射、是签名自身的类型参数、是点名的 TypeScript/Cordis 基础类型,或带有点名的例外及其非目录文档归属。违规会连同源码位置汇总报告,并点明相应的归属列表。该映射不会复用 `type-equiv.manifest.json`,因为后者记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。 +- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定版本的 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码位置),而非遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口仅在有意的 vendor 同步时才变化。 +- **指向数据结构目录的交叉链接。** 签名中由仓库拥有的每个类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition`……)都会通过人工维护的映射链接到其主要核心数据结构页面。AST 遍历采用默认拒绝放行的策略:每个参数、泛型约束/默认值和返回类型引用都必须已映射、是签名自身的类型参数、是点名的 TypeScript/Cordis 基础类型,或带有点名的例外及其非目录文档归属。违规会连同源码位置汇总报告,并点明相应的归属列表。该映射不会复用 `type-equiv.manifest.json`,因为后者记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。 - **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,并把原始事件或公共方法 JSDoc 直接放在其声明之前。`doc-typecheck` 会识别并跳过这些裸片段,将其排除在 opt-out 比例之外——与 `type-equiv` 块的处理相同。 -本决策**取代** [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)中事件分类的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 +本决策**取代** [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)中事件分类体系的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 ## 曾考虑的替代方案 -- **校验而非生成(退役的分类检查所做的事)**:*仅对本参考面*反转了这一策略。此处的数据可以机械地完整获取,因此生成严格强于对手工表格做名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 -- **遍历 vendor AST 以获取继承层**:否决,改用人工维护表格。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 接口面仅在有意同步时才变化。 -- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用完整的人工维护常量和失败关闭覆盖。清单记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。显式映射让每个渲染目标和每个非目录例外都成为可评审的决策。 +- **校验而非生成(退役的分类体系检查所做的事)**:*仅对本参考面*反转了这一策略。此处的数据可以机械地完整获取,因此生成严格强于对手工表格做名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 +- **遍历 vendor AST 以获取继承层**:否决,改用人工维护表格。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 接口仅在有意同步时才变化。 +- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用完整的人工维护常量和默认拒绝放行的覆盖检查。manifest 记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。显式映射让每个渲染目标和每个非目录例外都成为可评审的决策。 ## 后果 - 目录不会发生漂移:提交文件未反映的源码变化会使 `doc-sync` 和 CI 中的 `verify-cordis-catalog` 失败。新事件缺少 `@mode` 标签、标签与其签名冲突,或签名类型未分类,都会直接使生成器失败。 - 事件与服务方法契约只有一个归属——声明处的 JSDoc。目录会在生成的签名块中重复该原始 JSDoc,并使用其描述部分作为条目正文,因此单薄的源码文档只会生成单薄的目录条目。 -- 继承层是手工摘要,因此 vendor 同步若新增或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的人工维护表格。这是不遍历固定 vendor 源码的有意代价;它很少变化,且在生成器中有明确标注。 +- 继承层是手工摘要,因此 vendor 同步若新增或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的人工维护表格。这是不遍历固定版本的 vendor 源码的有意代价;它很少变化,且在生成器中有明确标注。 - `verify-event-taxonomy.ts` 被删除,`docs/architecture.md` 的事件表格也已移除;之前链接到特定表格行的人现在会落在生成目录上。 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index d3ee2b2a03..9e69041e2e 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-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/process/2026-07-02-bilingual-docs-and-pairing-gate.md 2026-07-02-bilingual-docs-and-pairing-gate.md: 8e9d0ab8653528517346b198c97814d8d8979440 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: cf37d56be0180cc742b0dabefeb1f8f3b54ba557 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: e8f18ebb0d1620830116f6d5d9ffef29664a578e diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index cf37d56be0..e8f18ebb0d 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -1,4 +1,4 @@ -# Agent Note:通过配对兄弟文件与配对门禁实现双语文档 +# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档 Status: implemented diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 65a2b2da28..b1098546b7 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md 2026-07-02-tool-schema-catalog.md: c8cc69df428f6eee0f66ed976865afe2a0702448 -2026-07-02-tool-schema-catalog.zh.md: f08cb5b5312dd07f91037a4382bf5e416cae552d +2026-07-02-tool-schema-catalog.zh.md: a1af928cc799974b93d2c8e29792790bc1f4e9e6 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md index f08cb5b531..a1af928cc7 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 生成式工具 schema 目录(启动并采集) +# Agent Note: 生成的工具 schema 目录(启动并采集) Status: implemented @@ -10,7 +10,7 @@ Status: implemented ## 决策 -目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(package);该上下文还提供 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入 seam。生成器调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## ` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 +目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包;该上下文还提供 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入 seam。生成器调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后 dispose(资源释放)上下文,并为每个包渲染一个 `## ` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 ### 为何启动而非解析(核心要点) @@ -35,7 +35,7 @@ Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是 `packages/*/tool-*` 下已发布的产品工具包,每个都使用默认配置启动,包括 `dsh-tool-bash`(`bash`)、`dsh-tool-tasks`(`task_output`、`task_list`、`task_kill`)和 `dsh-tool-subagent`(`subagent`)。仅供示例使用的工具不在范围内。 -目录的单位是包,而非每个配置化的工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举所有部署排列。部署清单是一个独立的、无界的接口。 +目录的单位是包,而非经过配置的每个工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举所有部署配置组合。部署清单覆盖的是一个独立且无界的范围。 ### 使用普通 `json` 围栏 @@ -49,7 +49,7 @@ schema 块使用 ` ```json `,而非自定义的 `ts` 系围栏。`doc-typechec ## 后果 -- 目录不会发生漂移:提交文件未反映的工具 schema 变化会使 `doc-sync` 和 CI 中的 `verify-tool-catalog` 失败。新增的 `tool-*` 包若未加入清单,会直接使完整性守卫失败。 +- 目录不会发生漂移:提交文件未反映的工具 schema 变化会使 `doc-sync` 和 CI 中的 `verify-tool-catalog` 失败。新增的 `tool-*` 包若未加入 manifest,会直接使完整性守卫失败。 - 工具描述文本有唯一归属——源码中 `defineTool` 的 `description`——生成的条目质量取决于它,与 Cordis 目录对事件 JSDoc 施加的强制力相同。 - 生成器导入并执行工作区包(这是仓库中第一个这样做的脚本;其他脚本只读文本)。它通过根 `tsconfig` 的 `paths` 映射在 `tsx` 下运行,使用与演示和测试相同的未构建源码路径,因此不需要构建步骤。 - 未来某个工具背后新增一个能力 seam,意味着 manifest 中需要新增一条配方条目(声明要挂载哪些 seam)。这正是上文指出的有意为之的手写成本;仅在新增工具包时才需变更。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 0a8609229e..ff01e8a69a 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-04-doc-tiers-and-budgets.md: a52c40a9a147fd39fdec4c61079822f1b1115227 -2026-07-04-doc-tiers-and-budgets.zh.md: d03c2046c9963e4d62d2a7221d4563f60d3f4953 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +2026-07-04-doc-tiers-and-budgets.md: e7b3421d09a1ae5ab9a9373e8040832c1b0d4b97 +2026-07-04-doc-tiers-and-budgets.zh.md: 18bf98777c87512e6161a4529db66030ced47bfd diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md index a52c40a9a1..e7b3421d09 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -1,4 +1,4 @@ -# Agent Note: Documentation tiers, budgets, and the ceiling gate +# Agent Note: Documentation structure, tiers, and budgets Status: implemented @@ -6,13 +6,14 @@ English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md) ## Problem -Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy. +Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. That guidance also did not define how a document's place in the hierarchy limits its scope or how ordered teaching differs from lookup-oriented material. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy. ## Decision -- **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc. +- **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge. +- **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc. - **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. -- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act. +- **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification. - **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract. ## Alternatives considered @@ -23,6 +24,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m ## Consequences -- Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. -- The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth. +- Adding to a budgeted doc requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. +- Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place. +- Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index d03c2046c9..18bf98777c 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 文档分层、预算与上限门禁 +# Agent Note: 文档结构、层级与预算 Status: implemented @@ -6,23 +6,25 @@ Status: implemented ## 问题 -尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事件、重复的包(package)映射,以及陈旧的 Agent Note(agent 决策记录)摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。 +尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事件、重复的包(package)映射,以及陈旧的 Agent Note(agent 决策记录)摘要。该指导也未明确文档在层级中的位置如何限定其内容范围,以及按顺序引导读者学习的内容与面向查阅的材料有何不同。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。 ## 决策 -- **每项事实只归属一处的层级分类。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事件故事、操作指南、各包契约、生成式目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单。 +- **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.md) 仍不受这一结构契约约束。每份面向人的文档要么是按顺序引导读者达成结果的教程(tutorial),要么是查阅范围明确的参考文档(reference);[事故复盘(postmortem)](../../../../docs/postmortem/README.md) 是范围限定于单个事件的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念。 +- **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单。 - **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 -- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 +- **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600,但 `packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250;`packages/README.md` ≤ 600)。门禁变红时,迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才提高上限。 - **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。 ## 曾考虑的替代方案 -- **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有机械后盾的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)认为值得保持的不变式就值得编码。 -- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如特性矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。 +- **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有自动化保障的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)认为值得保持的不变式就值得编码。 +- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。 - **将标准放在 skill 内部**:否决。契约归文档,工作流归 skill;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent(智能体)就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。 ## 后果 -- 向受预算约束的文档添加内容现在需要置换:将新增内容迁移到其分类体系归属地并留下指针,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。 -- 精简到目标预算的重写以堆叠的后续 PR 落地,每次合并时同步下调 manifest 中的上限;在各自落地之前,文档冻结的上限仅阻止进一步膨胀。 +- 向受预算约束的文档添加内容需要置换:将新增内容迁移到其分类体系归属地并留下指针,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。 +- 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色。 +- 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。 - 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index 1cf70223f5..040772a879 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md 2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b -2026-07-05-uniform-agent-note-format.zh.md: df6b0f4dfacf122f452807491680091827f69c25 +2026-07-05-uniform-agent-note-format.zh.md: 8f874f26c43a88ec3d2f1185c7b4d6f2f39ba1bb diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md index df6b0f4dfa..8f874f26c4 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -Agent Note(agent 决策记录)的路径编码了生命周期和类别,但文件内容仍混杂着不同标题、状态格式、ADR 与提案模板,以及已实现记录中的提案阶段章节。作者会复制随手找到的相邻文件,而生命周期迁移可能跳过必要的改写,因为没有门禁强制执行文件内契约。 +Agent Note 的路径编码了生命周期和类别,但文件内容仍混杂着不同标题、状态格式、ADR 与提案模板,以及已实现记录中的提案阶段章节。作者会复制随手找到的相邻文件,而生命周期迁移可能跳过必要的改写,因为没有门禁强制执行文件内契约。 ## 决策 -[README.md § 文件格式](../../README.md#the-file-format)是文件内契约——头部块(`# Agent Note: `,加上无日期且与文件夹一致的 `Status:` 枚举,其中只有拒绝原因可作为额外内容)、各生命周期的正文骨架(所有文件均以 `Problem` 开篇;`proposed/` 使用 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 使用现在时的 `Decision`/`Consequences`,并禁止提案阶段标题;`rejected/` 冻结提案形状)、强制的 `Alternatives considered` 章节,以及规范章节词汇;定制技术章节可在这些规范章节之间保持自由形式。`pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../../../scripts/verify-agent-note-format.ts))作为 `doc-sync` 的一部分强制执行每项机械规则,因此跳过改写的生命周期迁移现在会使 CI 失败,而不再依赖评审者记忆。 +[README.md § 文件格式](../../README.md#the-file-format)是文件内契约——头部块(`# Agent Note: <title>`,加上无日期且与文件夹一致的 `Status:` 枚举,其中只有拒绝原因可作为额外内容)、各生命周期的正文骨架(所有文件均以 `Problem` 开篇;`proposed/` 使用 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 使用现在时的 `Decision`/`Consequences`,并禁止提案阶段标题;`rejected/` 冻结提案结构)、强制的 `Alternatives considered` 章节,以及规范章节词汇;定制技术章节可在这些规范章节之间保持自由形式。`pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../../../scripts/verify-agent-note-format.ts))作为 `doc-sync` 的一部分强制执行每项机械规则,因此跳过改写的生命周期迁移现在会使 CI 失败,而不再依赖评审者记忆。 -定义该格式的同一变更规范化了整个语料库——遵循预发布立场:不设过渡期,不容忍双格式。唯一受既有条款豁免的是内容,而非格式:替代方案只能记录、不能杜撰,因此若某份格式制定前的 Agent Note 无法从记录中还原替代方案,就会携带确切的 `agent-note-format: alternatives-not-recorded` 注释;门禁只对日期早于本文的文件接受该注释。 +定义该格式的同一变更规范化了整个语料库——遵循预发布立场:不设过渡期,不容忍双格式。唯一适用既有内容豁免的是内容,而非格式:替代方案只能记录、不能杜撰,因此若某份格式制定前的 Agent Note 无法从记录中还原替代方案,就会带有内容完全匹配 `agent-note-format: alternatives-not-recorded` 的注释;门禁只对日期早于本文的文件接受该注释。 ## 曾考虑的替代方案 -- **完整的刚性模板**(每个生命周期使用固定章节顺序,重构每份 Agent Note 以适配):否决。大型设计 Agent Note 包含八到十五个定制技术章节(包(package)拓扑、线协议、schema),它们是承载设计的内容,而非漂移;刚性顺序会迫使我们现在进行破坏性改写,并永远与模板较劲。 +- **完整的刚性模板**(每个生命周期使用固定章节顺序,重构每份 Agent Note 以适配):否决。大型设计 Agent Note 包含八到十五个定制技术章节(包拓扑、协议契约、schema),它们是承载设计的内容,而非漂移;刚性顺序会迫使我们现在进行破坏性改写,并永远与模板较劲。 - **仅规范化头部**(H1 和 Status,正文不动):否决。债务标记指出的是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 - **不设 Status 行**(文件夹已经表示状态;格式制定前最新的三份 Agent Note 及其中一份的中文对应文件省略了该行):否决,保留文件的自描述性。通过门禁校验该行与文件夹一致,消除了原本促使我们删除它的漂移风险。 - **带日期的 Status**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息;门禁能检查日期格式,但永远无法检查其真实性。 @@ -27,4 +27,4 @@ Agent Note(agent 决策记录)的路径编码了生命周期和类别,但 ## 后果 -现在每份 Agent Note 都需要稍多一些结构,而强制的 `Alternatives considered` 章节是有意设置的阻力:记录决策却不记录它胜过什么,会招致 Agent Note 本应防止的重新争论。无法还原替代方案的格式制定前 Agent Note 会永久保留既有条款注释——这是记录中诚实的缺口,而不是杜撰的理由。`doc-sync` 增加一道门禁;在生命周期文件夹之间移动 Agent Note 时,现在必须当场完成真正的工作(迁移本就应包含的正文改写),而不是推迟为无人跟踪的清理任务。三十九个债务标记已经消失,由它们一直等待的模板解决。 +现在每份 Agent Note 都需要稍多一些结构,而强制的 `Alternatives considered` 章节是有意设置的阻力:记录决策却不记录它胜过什么,会招致 Agent Note 本应防止的重新争论。无法还原替代方案的格式制定前 Agent Note 会永久保留既有内容豁免注释——这是记录中诚实的缺口,而不是杜撰的理由。`doc-sync` 增加一道门禁;在生命周期文件夹之间移动 Agent Note 时,现在必须当场完成真正的工作(迁移本就应包含的正文改写),而不是推迟为无人跟踪的清理任务。三十九个债务标记已经消失,由它们一直等待的模板解决。 diff --git a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml index 8ab5f0a167..8c2e77710e 100644 --- a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-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/process/2026-07-06-export-surface-jsdoc-gate.md 2026-07-06-export-surface-jsdoc-gate.md: 821f68ebe60eba44cade223b3bc8286e09740956 -2026-07-06-export-surface-jsdoc-gate.zh.md: 6cfa28dc43ae2bdf46ec5d997a1a96d3e308559b +2026-07-06-export-surface-jsdoc-gate.zh.md: 5f5f7b70a486b0aa5e8e7fd07810dfbcfb719a50 diff --git a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md index 6cfa28dc43..5f5f7b70a4 100644 --- a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 导出表面 JSDoc 门禁 +# Agent Note: 导出接口 JSDoc 门禁 Status: implemented @@ -6,25 +6,25 @@ Status: implemented ## 问题 -[Cordis JSDoc 完整性门禁](../../archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md)使得 Cordis 表面上的参数和返回值不可能缺少文档——`interface Events` 成员和 `ctx.<key>` 服务类——但这只是插件作者所导入内容的一小部分。AGENTS.md 中的规则「每个导出(以及非显而易见的方法)都必须有解释语义的 JSDoc」在其他地方只能靠评审以行文方式检查,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包(package)中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。 +[Cordis JSDoc 完整性门禁](../../archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md)使得 Cordis 接口上的参数和返回值不可能缺少文档——`interface Events` 成员和 `ctx.<key>` 服务类——但这只覆盖插件作者可导入接口的一小部分。AGENTS.md 中的规则「每个导出(以及非显而易见的方法)都必须有解释语义的 JSDoc」在其他地方仍只是由评审检查的文字约定,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。 ## 决策 -新增门禁 `scripts/verify-export-jsdoc.ts`(`pnpm run verify-export-jsdoc`,接入 `doc-sync`(文档同步门禁),与 `verify-cordis-catalog` 并列),遍历每个 `packages/<group>/<pkg>/src/` 目录树下的所有模块级导出名称。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`,使得「已文档化」在两个表面上含义一致:描述性文字在第一个块标签处截止、每个可检查参数需要非空 `@param`、非 void 且有显式标注的返回值需要非空 `@returns`、过时的 `@param` 报错,违规项汇总为一份报告。 +新增门禁 `scripts/verify-export-jsdoc.ts`(`pnpm run verify-export-jsdoc`,接入 `doc-sync`(文档同步门禁),与 `verify-cordis-catalog` 并列),遍历每个 `packages/<group>/<pkg>/src/` 目录树下的所有模块级导出名称。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`,使得「已文档化」在两类接口上含义一致:描述性文字在第一个块标签处截止、每个可检查参数需要非空 `@param`、非 void 且有显式标注的返回值需要非空 `@returns`、过时的 `@param` 报错,违规项汇总为一份报告。 按声明类型划分的契约: - 每个导出名称都需要带有非空描述文字的 JSDoc。 -- 函数类导出(函数声明;初始化器为函数或带有内联可调用标注的 const;非标识符的函数默认导出)遵循完整的函数契约,分类前会剥离包装表达式(括号、`as`/`satisfies` 类型断言、非空断言)。如果 const 声明器标注了一个具名类型(`export const f: Handler = …`),签名契约推迟到该类型自身的声明处,`@returns` 保持可选;内联的 `(x: T) => U` 标注或单调用签名字面量本身就是表面签名,适用完整契约;而混合了调用/构造签名与其他成员的字面量则直接拒绝(没有单一签名可供标签对照——请提取具名类型)。 +- 函数类导出(函数声明;初始化器为函数或带有内联可调用标注的 const;非标识符的函数默认导出)遵循完整的函数契约,分类前会剥离包装表达式(括号、`as`/`satisfies` 类型断言、非空断言)。如果 const 声明器标注了一个具名类型(`export const f: Handler = …`),签名契约推迟到该类型自身的声明处,`@returns` 保持可选;内联的 `(x: T) => U` 标注或单调用签名字面量本身就是对外签名,适用完整契约;而混合了调用/构造签名与其他成员的字面量则直接拒绝(没有单一签名可供标签对照——请提取具名类型)。 - 导出类需要类级别的描述文字;公开方法(包括静态方法——可通过导出名称访问)遵循函数契约;公开属性和访问器需要描述文字(get/set 对由 getter 覆盖)。重载实现体免检——签名承载文档。 - 导出接口、类型别名和枚举需要声明级别的描述文字;成员级别的强制有意推迟(承载关键成员契约的 seam 服务类已在 Cordis 门禁之下)。 - 导出命名空间递归检查(在 ambient `declare` 命名空间内,每个成员隐式导出);命名空间本身仅在不与同名的已文档化声明合并时才需要描述文字(Config-namespace 惯用法只需文档化插件一次)。 - `declare module`/`declare global` 体和 `export … from` 重导出语句被跳过:augmentation 不是包的导出,重导出的定义在其定义处检查。`export import X = N.member` 别名需要文档化自身——其目标可能是遍历不会访问的非导出命名空间成员——且门禁仅支持纯描述文字的目标类型:可调用、类或命名空间目标携带别名描述文字无法承载的签名/成员契约,门禁会拒绝并要求直接导出该声明。 -- 其余情况按封闭原则失败:`export =` 直接拒绝;基类从未命名的参数即使作为绑定模式仍需 `@param`;dispatch 不识别的导出语句类型本身就是违规——没有任何导出形式能因遗漏而免检。 +- 其余情况一律默认拒绝:`export =` 直接拒绝;即使参数使用绑定模式,只要基类未为其命名,仍需 `@param`;dispatch 不识别的导出语句类型本身就是违规——没有任何导出形式能因遗漏而免检。 三类豁免避免门禁要求样板代码,精神与 Cordis 门禁的 `this`/`next` 豁免一致(为已豁免的名称编写文档是允许的;只有缺失才不被检查): -- **继承成员。** 重写从其基类声明继承文档。新增的公开表面仍需文档:新增参数、将 protected 成员公开重写、或在 void 基类之上返回具体类型。继承查找和推断返回值分类是门禁唯一需要类型检查器的工作;其他检查使用 AST。 +- **继承成员。** 重写从其基类声明继承文档。新增的公开接口仍需文档:新增参数、将 protected 成员公开重写、或将基类的 void 返回改为具体类型。继承查找和推断返回值分类是门禁唯一需要类型检查器的工作;其他检查使用 AST。 - **插件协议槽位。** 顶层的 `name`/`inject`/`reusable`/`Config` 常量和 `apply` 入口,以及插件类上的同名静态成员,属于框架协议:其形状由 Cordis 固定,模块文档注释加 `interface Config` 承载插件的真实语义。 - **构造函数**,与 Cordis 门禁一致:插件类由框架构造,类文档承载全部说明。 @@ -34,11 +34,11 @@ Status: implemented - **eslint-plugin-jsdoc**(`require-jsdoc`/`require-param`/`require-returns`):覆盖了机械核心,但无法表达本仓库的契约。继承成员豁免需要跨包的类型解析,协议槽位和命名空间合并惯用法是 Cordis 特有的,而完整性语义(标签前描述文字、过时标签报错、汇总报告)已在 `scripts/jsdoc.ts` 中与 catalog 生成器共享。两套微妙不同的「已文档化」定义,正是本仓库「单一归属」规则所要防止的失败模式。 - **扩展 `gen-cordis-catalog.ts`**:catalog 生成器渲染一个精选表面并守卫其新鲜度;仓库级遍历没有 catalog 可渲染。共享辅助函数、保持遍历独立,使每个门禁的职责清晰可读。 -- **强制接口/类型别名的成员文档**:推迟。这会使检查表面成倍增长,而这些成员大多是自描述的字段;承载关键成员契约的 seam 服务类已有门禁。如果评审中出现成员文档漂移再重新考虑。 +- **强制接口/类型别名的成员文档**:推迟。这会使检查表面成倍增长,而检查对象大多只是含义直观的字段;承载关键成员契约的 seam 服务类已有门禁。如果评审中出现成员文档漂移再重新考虑。 ## 后果 -- 新导出不能在缺少文档的情况下落地:`verify-export-jsdoc` 会使 `doc-sync` 和 CI 失败。采纳时发现的 203 处缺口已在同一变更中补齐,因此门禁以绿色状态落地。 +- 新导出不能在缺少文档的情况下落地:`verify-export-jsdoc` 会使 `doc-sync` 和 CI 失败。采纳时发现的 203 处缺口已在同一变更中补齐,因此门禁落地时所有检查均已通过。 - 导出函数必须标注返回类型(采纳时已全面满足,现在成为门禁依赖),并在 `@param` 需要命名参数时使用标识符参数。 - seam 文档是权威的:实现从其继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 - 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已编译文档片段的 `doc-sync` 内可以接受。 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 50d3e498a5..aa4c089a00 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.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/process/2026-07-06-node-engine-floor.md 2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7 -2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0 +2026-07-06-node-engine-floor.zh.md: c409b006baa3451eb5fc5260be4e14ffe88776c8 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index a0281addf7..c409b006ba 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖包(package)所声明的 `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 +根 `engines.node` 范围中的 Node 22 分支是对安装后工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖包所声明的 `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式下的安装结果则会在依赖所支持的运行时范围之外运行。 ## 决策 @@ -19,21 +19,21 @@ Status: implemented 这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的包声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 -`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是编译通过、直到仅下限矩阵分支才能捕获的运行时错误才暴露。目前整个代码树在 Node 22 类型表面上类型检查全部通过,因此这一固定没有任何代价。 +`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是先编译通过,直到下限矩阵分支运行时才暴露错误。目前整个代码树针对 Node 22 类型接口的类型检查全部通过,因此固定该版本不产生任何代价。 ## 后果 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 - CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 -- 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。 +- 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note。 ## 曾考虑的替代方案 - **保持 `^22.18.0 || >=24.0.0`。** 否决:它宣传的 LTS 版本低于 Pi 适配器依赖的下限。`@earendil-works/pi-ai@0.79.3` 要求 `>=22.19.0`。 - **降级或固定 `@earendil-works/pi-ai` 以保留 22.18 的宣传范围。** 否决:当前 Pi 适配器依赖是预期工作区的一部分,且 22.19 仍在 Node 22 LTS 线内。 - **下限 `>=22.13`(`node:sqlite` 边界)加上在 22.13–22.17 的 built-bin 冒烟测试中使用 `--experimental-strip-types`。** 否决:它为一个狭窄范围增加了版本条件测试标志,并将实验性标志依赖包装为正式支持。Pi 适配器依赖已经要求更高的 LTS 下限。 -- **开放式 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需标志。 -- **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无标志运行两个源码特性,但 Node 23 已 end-of-life;宣传一条已终止的发布线会增加一个范围项和一条 CI 分支,而没有任何部署应当使用该运行时。 +- **使用无上限的 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需标志。 +- **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无标志运行两个源码特性,但 Node 23 已结束生命周期(EOL);宣传一条已终止的发布线会增加一个范围项和一条 CI 分支,而没有任何部署应当使用该运行时。 - **矩阵 `[22, 24, 26]` 而非固定 `22.19`。** 否决:浮动的主版本号条目会随时间上漂,悄然不再验证所声明的 LTS 下限。 - **保持 `@types/node` 超前于运行时下限(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上可将此类问题转化为所有环境下的编译错误。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 8547b425d1..09a9e73b71 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.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/process/2026-07-06-parallel-pre-push-gates.md 2026-07-06-parallel-pre-push-gates.md: 538e52c5318fb6d4eab2e8786513a08c1ff0ec55 -2026-07-06-parallel-pre-push-gates.zh.md: e93eec8757c20c8154703d9bdfd2f0c805e6a26c +2026-07-06-parallel-pre-push-gates.zh.md: 0830e99484ebad40aa28ba6d2cfed1f09cfbee42 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index e93eec8757..0830e99484 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -4,21 +4,21 @@ Status: implemented [English](2026-07-06-parallel-pre-push-gates.md) | 中文 -本记录中的本地 hook 部分已由[快速本地 Git hook](2026-07-22-fast-local-git-hooks.md) 取代。有界门禁调度器和包(package)级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。 +本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。 ## 问题 -文档同步等聚合 job 隐藏了很长的串行链,其成员只读且相互独立。在工作流 YAML 中重复这些叶子清单,会使未来脚本变更有多个位置可以发生漂移;而串行运行包发布检查,会使一道门禁的耗时与包数量成正比。 +文档同步等聚合任务隐藏了很长的串行链,其中各项检查只读且相互独立。在工作流 YAML 中重复这些叶子清单,会使未来脚本变更有多个位置可以发生漂移;而串行运行包发布检查,会使一道门禁的耗时与包数量成正比。 ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 -Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 +Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 -各门禁的包脚本仍是临时本地运行所用的词汇。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 在调度器中拥有其成员列表([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 +各门禁的包脚本仍是临时本地运行所用的命令入口。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 的成员列表由调度器管理([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 ## 验证 @@ -30,12 +30,12 @@ Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell - **每个叶子门禁声明一个 CI job**:暴露最大工作流并行度,但会重复 checkout、设置和安装开销,并在 YAML 中复制调度器清单。 - **在 shell 脚本内后台运行子命令**:可以并行处理,但会失去各门禁计时、确定性的失败分组和直接的信号处理。 - **每个包声明一个 `publint` job**:暴露最大包级并行度,但会创建手工维护的包清单,包发生变化时就会漂移。 -- **以无界并发运行 `publint`**:只有通过拿进程数、内存压力、包 tarball 创建和可读日志冒险,才能最大限度缩短小型仓库的耗时。 +- **以无界并发运行 `publint`**:虽能最大限度缩短小型仓库的耗时,却会拿进程数量、内存压力、包 tarball 创建开销和日志可读性冒险。 ## 后果 -由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 +由调度器支持的命令耗时取决于最慢的依赖链,而非各独立门禁耗时之和,并会报告决定总耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 -这条验证链会让已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 +这条验证链会让使用已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 `publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index f24a929889..c4d91684d7 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md 2026-07-10-readme-known-limitations-gate.md: 2ca1168d795692730d17b6ab23dd113e8be277e5 -2026-07-10-readme-known-limitations-gate.zh.md: 4e42492f501cca1a45a90694acea4ca78e920780 +2026-07-10-readme-known-limitations-gate.zh.md: cabb368af5b3e35ffcb8713b2c96fdb767f6237d diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index 4e42492f50..cabb368af5 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 每个包(package)README 中受门禁保护的 Known Limitations 章节 +# Agent Note: 每个包 README 中受门禁保护的「已知限制」章节 Status: implemented @@ -6,21 +6,21 @@ Status: implemented ## 问题 -[文档标准](../../../../docs/AGENTS.md)规定限制项归属包 README。没有共享形状时,缺少章节无法区分“经审计确认没有限制”与“忘记编写文档”,不同的标题还会妨碍全仓库搜索。 +[文档标准](../../../../docs/AGENTS.md)规定限制项归属包 README。没有统一结构时,章节缺失便无法区分“经审计确认没有限制”与“忘记编写文档”,不同的标题还会妨碍全仓库搜索。 ## 决策 -`packages/<group>/<pkg>/package.json` 下的每份包清单都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。其项目符号记录由该包拥有的持久消费方缺口和不明显的维护者约束;普通清理仍留在源码 TODO 或所属 Agent Note(agent 决策记录)中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从清单推导包集合,拒绝缺失 README,并要求恰好一个规范 h2 且至少包含一个顶层项目符号。“Limitations”“Deferred”“What is NOT here”或“Non-goals”等近似标题都会失败。 +每份位于 `packages/<group>/<pkg>/package.json` 的 manifest(元数据清单)都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。其中的项目符号记录由该包负责的长期消费方缺口和不明显的维护者约束;一般清理事项仍留在源码 TODO 或所属 Agent Note 中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从 manifest 推导包集合,拒绝缺失 README,并要求恰好一个规范的 H2 标题,且至少包含一个顶层项目符号。“Limitations”“Deferred”“What is NOT here”或“Non-goals”等近似标题都会失败。 -如果一个包确实没有需要声明的限制事项,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制事项时须移除该条目;重命名或移除条目会失败,因为每个条目都必须对应一个被扫描的包。 +如果一个包确实没有需要声明的限制事项,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制事项时须移除该条目;包重命名或移除后,陈旧条目会使门禁失败,因为每个条目都必须对应一个被扫描的包。 -门禁检查存在性、形状和允许列表。按照文档与[正文](../../../skills/dsh-prose-standard/SKILL.md)标准进行的评审负责覆盖面和准确性。常设规则位于 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 +门禁检查存在性、形状和允许列表。评审依据文档与[行文](../../../skills/dsh-prose-standard/SKILL.md)标准检查覆盖面和准确性。常设规则位于 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 ## 曾考虑的替代方案 - **自由格式标题**:无法统一搜索,仍需近似标题检测。 -- **要求空章节或写 "None."**:样板文字可能在包新增限制事项后仍然残留;白名单使「确无限制」这一状态显式且可评审。 -- **设置字数上限**:合理的限制事项数量因包而异,因此由评审管控这一不设预算的 README 层级。 +- **要求空章节或写 "None."**:样板文字可能在包新增限制事项后仍然残留;允许列表使「确实没有限制事项」这一状态显式且可评审。 +- **设置词数上限**:合理的限制事项数量因包而异,因此由评审管控这一不设词数预算的 README 层级。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index ab312d8bcd..2bfcddbf3b 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md 2026-07-12-package-model-experience-contract.md: 92a8e5a1a81d00dae085e4af89456896373058e6 -2026-07-12-package-model-experience-contract.zh.md: 54b181738b8276c634f777ad3424191c8652baec +2026-07-12-package-model-experience-contract.zh.md: 34dd909adc6a3a5f4c9113df675d31012b836370 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md index 54b181738b..34dd909adc 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 包(package)的模型体验契约 +# Agent Note: 包的模型体验契约 Status: implemented @@ -6,17 +6,17 @@ Status: implemented ## 问题 -包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的提示词或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 +包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV Cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的提示词或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 ## 决策 -每个具有面向模型或邻近模型契约的 workspace 包 README 都以规范的[模型体验章节](../../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme)收尾,位置紧邻 `## Known Limitations and Deferred Work` 之前;位于“无限制项”允许列表中的包则以模型体验本身结尾。经审计确认与模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 +每个具有面向模型或邻近模型契约的 workspace 包 README 都以规范的[模型体验章节](../../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme)收尾,位置紧邻 `## Known Limitations and Deferred Work` 之前;位于“无已知限制”允许列表中的包则以模型体验章节本身结尾。经审计确认与模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 -具有直接、条件式、有上限、全生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。cache 字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、范围、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺提供方一定命中 cache 或保留某段时间。由包拥有的稳定文本按原文精确引用:系统提示词正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成式[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由提供方拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏提示词与 schema 中的一者而不影响另一者时,两种表面保持分离。 +具有直接、条件式、有上限、生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。缓存字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、作用域、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺提供方一定能命中缓存或将其保留特定时长。由包拥有的稳定文本按原文精确引用:系统提示词正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成的[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由提供方拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏提示词与 schema 中的一者而不影响另一者时,两种表面保持分离。 -没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。提供方后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费方。结构化章节同样只记录由包拥有的输入、变换和增量。 +没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。提供方后端即使会限制或过滤数据,也使用间接格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节会指出贡献所在,并声明不会直接导致 KV Cache 失效,同时不重复陈述消费方。结构化章节同样只记录由包拥有的输入、变换和增量。 -`verify-package-readme-model-experience` 发现包清单,并验证三种分类、规范末尾章节顺序、确切字段标题深度与顺序、非空字段段落、逐字块的 H5 归属、具体字面量证据,以及带锚点的工具目录链接。它在 `doc-sync` 和并行门禁 runner 中运行。评审仍负责覆盖面、链接相关性和事实准确性。 +`verify-package-readme-model-experience` 发现各包的 manifest(元数据清单),并验证三种分类、规范末尾章节顺序、确切字段标题深度与顺序、非空字段段落、逐字块的 H5 归属、具体字面量证据,以及带锚点的工具目录链接。它在 `doc-sync` 和并行门禁 runner 中运行。评审仍负责覆盖面、链接相关性和事实准确性。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -评审者可以从任何面向模型或邻近模型的包开始,看到它对对话模型、子模型和辅助调用的贡献,无需重建完整插件图。token 预算工作可以区分重复请求开销和依赖数据的历史,而 cache 敏感工作可以识别仅追加路径,以及最早由包引起的前缀变更。agent 范围变更有明确的文档检查点。每当模型可见行为发生变化时,包作者都要维护一个或多个紧凑的上下文表面块,或一种已分类的短格式;经审计的通用包不携带无关的模型样板。结构化字段不承诺由提供方给出的精确 token 数或 cache 命中;测量仍取决于具体模型、提供方和工作负载,而所记录的增长、可见性和前缀稳定性契约保持稳定。 +评审者可以从任何面向模型或邻近模型的包开始,看到它对对话模型、子模型和辅助调用的贡献,无需重建完整插件图。token 预算工作可以区分重复请求开销和依赖数据的历史,而 cache 敏感工作可以识别仅追加路径,以及最早由包引起请求前缀变化的位置。agent 范围变更有明确的文档检查点。每当模型可见行为发生变化时,包作者都要维护一个或多个紧凑的上下文表面块,或一种已分类的短格式;经审计的通用包不携带无关的模型样板。结构化字段不承诺由提供方给出的精确 token 数或 cache 命中;测量仍取决于具体模型、提供方和工作负载,而所记录的增长、可见性和前缀稳定性契约保持稳定。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 9a0f4cfbda..74ded5f605 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md 2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 -2026-07-13-documentation-site-projection.zh.md: 9df230ea8adeb8744387a5f7efdf288d6a1f6eaa +2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 9df230ea8a..6f1c79ac50 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将规范文档投影到网站 +# Agent Note: 将权威文档投影到网站 Status: implemented @@ -6,31 +6,31 @@ Status: implemented ## 问题 -仓库需要一个可导航的文档网站,但不能让网站目录成为第二个文档源。把包(package)指南、架构页面或生成目录复制到网站专用目录树,会使两份副本发生漂移;让 VitePress 直接指向仓库根目录,又会把公开 URL 和导航与内部文件布局耦合。仓库相对链接在网站上也需要指向不同位置:已发布页面应留在站内,源文件和未发布的贡献者文档则应指向 GitHub。 +仓库需要一个可导航的文档网站,但不能让网站目录成为第二个文档源。把包指南、架构页面或生成目录复制到网站专用目录树,会使两份副本发生漂移;让 VitePress 直接指向仓库根目录,又会把公开 URL 和导航与内部文件布局耦合。仓库相对链接在网站上也需要指向不同位置:已发布页面应留在站内,源文件和未发布的贡献者文档则应指向 GitHub。 ## 决策 -规范 Markdown 保留在拥有它的仓库层级中。面向产品的指南位于 `docs/user/`,生成的参考资料保留在现有生成目录中,架构页面和实操手册(cookbook)页面也保留在现有的 `docs/` 路径。 +权威 Markdown 保留在其所属的仓库层级中。面向产品的指南位于 `docs/user/`,生成的参考资料保留在现有生成目录中,架构页面和实操手册(cookbook)页面也保留在现有的 `docs/` 路径。 -`website/docs.ts` 是一份显式的发布 manifest(元数据清单)。每个条目将一个规范源文件映射到稳定的公开路由、侧边栏、分区和顺序。因此,新增或移除已发布页面是一项可评审的 manifest 变更,而不是隐式目录扫描的结果。 +`website/docs.ts` 是一份显式的发布 manifest(元数据清单)。每个条目将一个权威源文件映射到稳定的公开路由、侧边栏、分区和顺序。因此,新增或移除已发布页面是一项可评审的 manifest 变更,而不是隐式目录扫描的结果。 -在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由词汇。每个页面都会获得一个指向其规范仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 +在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由命名。每个页面都会获得一个指向其权威仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 -各 locale 的首页投影只保留规范 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 +各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影快速失败。单元测试固定这些转换,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 -`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举已跟踪且未被忽略的文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 +`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 -Mermaid 渲染规范图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 +Mermaid 渲染权威图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。 ## 考虑过的替代方案 -**在 `website/` 下提交复制的 Markdown。** 这种方式让 VitePress 设置更直接,但每份复制的指南或 API 表格都会多出一个所有者,并且需要一套无法识别权威副本的同步约定。 +**在 `website/` 下提交复制的 Markdown。** 这种方式让 VitePress 设置更直接,但每份复制的指南或 API 表格都会多出一个归属方,并且需要一套无法识别权威副本的同步约定。 -**让 `website/` 成为每个已发布页面的规范归属。** 这种方式仍只有一份副本,却只是为了满足渲染器,就把架构、生成的参考资料和面向贡献者的材料移出了各自的仓库归属层级。 +**让 `website/` 成为每个已发布页面的权威归属。** 这种方式仍只有一份副本,却只是为了满足渲染器,就把架构、生成的参考资料和面向贡献者的材料移出了各自的仓库归属层级。 **自动发现所有 Markdown 文件。** 这种方式最大限度减少 manifest 维护,却会意外发布内部文档、把源文件移动暴露为 URL 变更,并根据偶然的目录顺序生成导航。 @@ -42,6 +42,6 @@ Mermaid 渲染规范图表。网站工作区显式声明 `vitepress-plugin-merma ## 后果 -文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视规范输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复与验证入口。 +文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视权威输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复和验证的入口。 -发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留规范文档中已经使用的图表。 +发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留权威文档中已经使用的图表。 diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml index 50a6f1e0db..6c243f94db 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.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/process/2026-07-14-typescript-program-backed-semantic-gates.md 2026-07-14-typescript-program-backed-semantic-gates.md: 91639d53b660c68ae52c7ddcef6b3f594e82273e -2026-07-14-typescript-program-backed-semantic-gates.zh.md: 2270408564f0fc90241255dfb86a65c30362d651 +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 65e19ba2bd34c2411a2e5ca64dfc85f9bf18cfb3 diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md index 2270408564..65e19ba2bd 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md @@ -10,17 +10,17 @@ Status: implemented 当前的门禁基于 TypeScript 单文件语法解析能力,使用命名约定、手写的表格、JSDoc 等方式来维护这类信息。 -仓库需要一个语义真源,同时不能引入运行时包(package)之间的循环依赖、宽泛的兜底启发式逻辑,或重复描述 TypeScript 已有信息的机器可读标注。 +仓库需要一个语义真源,同时不能引入运行时包之间的循环依赖、宽泛的兜底启发式逻辑,或重复描述 TypeScript 已有信息的机器可读标注。 ## 决策 -仓库可以通过项目级类型信息 `ts.Program` 进行跨文件项目类型联合计算,并通过 `TypeChecker` 来提取 **强类型** 信息,用以缓解原有命名约定、手写表格、JSDoc 标注等形式。 +仓库门禁可以通过 `ts.Program` 汇集项目级类型信息,并使用 `TypeChecker` 提取**强类型**事实,从而减少对命名约定、手写表格和 JSDoc 元数据的依赖。 -当前已完成 A / B 两个门禁的语义化改造。 +仓库将这一模型应用于以下两个门禁。 ### 一个项目模型展开根项目配置 -[`TypeScriptProject`](../../../../scripts/ts-project.ts) 解析根 `tsconfig.json`,递归展开每个项目引用,并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时,TypeScript 可能将引用项目重定向到构建后的声明文件;显式展开可以让门禁继续遍历各包的 `src` 文件,并使用真实符号标识。 +[`TypeScriptProject`](../../../../scripts/ts-project.ts) 解析根 `tsconfig.json`,递归展开每个项目引用,并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时,TypeScript 可能将引用项目重定向到构建后的声明文件;显式展开可以让门禁继续遍历各包的 `src` 文件,并保留符号同一性。 该封装统一负责配置诊断、语义编译选项、仓库相对路径、源码查找和共享 TypeChecker。各门禁不再自行按文件通配模式扫描包源码,也不再分别构建不完整的 Program。 @@ -28,19 +28,19 @@ Status: implemented [`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) 根据调用接收者与仓库中真实 `Context`、`AgentEventDispatch` 和 Cordis `EventsService` 类型之间的可赋值关系进行分类。变量名和属性拼写不再决定某次调用是否属于事件操作。 -Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。 +Context 与 AgentEventDispatch 调用只贡献由字符串字面量构成的有限事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。 语义查询只在存在消费分支的位置运行:调用先经过封闭的事件 API 方法名集合预过滤,再做接收者分类;辅助函数调用点索引按需构建,而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数,按模块作用域规则其全部调用必在本文件内,此时只索引该文件。任一前提无法证明(带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用)即回退到原全部包源码索引,回退路径就是原语义本身:证明只影响开销,不影响结果。惰性单一全局索引方案被否决,因为当前源码树确实会走到辅助函数参数路径,该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。 -每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。 +每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为没有生产方的事件词汇或尚不支持的语义 dispatch 形态,并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。 ### B. 带作用域的事件路由生成一份强类型解析函数表 -[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) 扫描真实的 `scopeTarget(base, key)` 调用,为每种 scoped 基础对象确定路由键类型。随后,它查找带有 `this: Scoped<Base>` 的 Cordis `Events` 成员,并在每个事件参数及其一层公开属性中搜索类型;移除 `null` 和 `undefined` 后,候选类型必须与路由键类型完全相同。 +[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) 扫描真实的 `scopeTarget(base, key)` 调用,为每种 scoped 基础对象确定路由键类型。随后,它查找带有 `this: Scoped<Base>` 的 Cordis `Events` 成员,并在每个事件参数及其第一层公开属性中搜索与该键匹配的类型;移除 `null` 和 `undefined` 后,候选类型必须与路由键类型完全相同。 恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。 -仓库提交的 [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 契约,通过 checker 解析唯一的 payload 路径,并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。 +提交到仓库的 [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 契约,通过 checker 解析唯一的 payload 路径,并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。 `dsh-scope/invariant` companion 消费这份映射,不再维护手写事件表。Program 分析发生在仓库门禁内,而不是依赖生成的类型导入,因此 `dsh-scope` 和 `dsh-invariants` 都不需要依赖所有事件声明方。 @@ -50,7 +50,7 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 ## 验证 -`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查;`verify-scoped-events` 会重新运行 Program 分析,并检查生成映射的新鲜度。根 TypeScript 构建编译其运行时适配器;workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。 +`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查;`verify-scoped-events` 会重新运行 Program 分析,并检查生成映射的新鲜度。根 TypeScript 构建会编译该运行时适配器;workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml index 13b35d6f30..8297b3675e 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md 2026-07-19-remove-generated-agent-note-index.md: ee85ec0757d5924f5784c43a50003eb96e0a9531 -2026-07-19-remove-generated-agent-note-index.zh.md: 23e6d3b0b9aaaa02f53e72789f409c0050112193 +2026-07-19-remove-generated-agent-note-index.zh.md: 7ace1c4f03d5ff3d1137fe611a5ec0ec2ae21b2f diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md index 23e6d3b0b9..7ace1c4f03 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -生命周期/类别文件系统目录树就是 Agent Note 清单。[README.md](../../README.md) 继续作为人工维护的入口和契约,普通的目录树浏览与仓库搜索负责内容发现。 +按生命周期/类别组织的文件系统目录树就是 Agent Note 清单。[README.md](../../README.md) 继续作为人工维护的入口和契约,普通的目录树浏览与仓库搜索负责内容发现。 `scripts/agent-note-tree.ts` 持有封闭的生命周期/类别集合与结构遍历器。`verify-agent-note-classification` 校验该目录树,并拒绝旧目录和根目录中的 `INDEX.md`,但不会渲染集中式清单或检查其新鲜度。 @@ -22,10 +22,10 @@ Status: implemented **提供不提交到仓库的按需索引命令。** 这可以避免已提交文件的冲突,但仍需维护渲染器和命令,而目录树浏览与仓库搜索已经覆盖该发现路径。 -**恢复人工维护的索引。** 它具有相同的共享文件争用问题,还会重新引入生成机制已经避免的完整性和排序错误。 +**恢复人工维护的索引。** 它同样会造成共享文件争用,还会重新引入生成机制已经避免的完整性和排序错误。 ## 影响 - 添加、移动或重命名 Agent Note 时,不再改动覆盖整个语料库的生成文件。 -- 分类门禁执行的工作更少,文档门禁拓扑也不会增加进程或阶段。 +- 分类门禁执行的工作更少,文档门禁拓扑也无需增加进程或阶段。 - 读者不再获得单一的时间顺序页面,改用生命周期/类别目录树或仓库搜索。 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml index b2297f4fb4..fa3294857a 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md 2026-07-19-require-agent-notes-for-non-trivial-changes.md: 162ae61affb4c1b0ad526fa0da41f84ebbb02089 -2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: cd015ba62f1f2b1e9e5e6c36d1cde5bd35cba84c +2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 1578312ba43bfc79fe50841aaf651da6f0771fb9 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md index cd015ba62f..1578312ba4 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -每项实质性变更都在同一个 PR 中新增或更新至少一份 Agent Note。实质性变更包括行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘格式、线协议或配置格式,以及维护者可能合理重审的其他决策。 +每项实质性变更都在同一个 PR(Pull Request)中新增或更新至少一份 Agent Note。实质性变更包括行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘格式、协议格式(wire format)或配置格式,以及维护者可能合理重审的其他决策。 更新已经持有该决策的 Agent Note 即满足规则;仅当没有 Agent Note 持有该决策时才新增记录。完全机械或局部、且不改变行为、契约、结构、流程或决策依据的编辑可豁免。[Agent Notes README](../../README.md#when-to-write-one) 持有这条边界,根目录 `AGENTS.md` 则携带常驻指令。 -只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件和一致性记录。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。 +只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件和伴随记录。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。 后续决策完全移除较早的功能时,只有该功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行,移除记录才会成为当前持有记录。移除决策的依据和验证该功能已不存在的测试可以保留。它必须保留该功能的最初动机、为什么该动机已不足以证明继续保留该功能、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。只描述已删除行为的实现清单和测试已经过时,不属于当前验证契约。仅移除一种传输、默认值、实现或展示仍属于部分取代。 diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml index 9daf021e6e..bcd316a3df 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-19-web-styling-system.md 2026-07-19-web-styling-system.md: b4d647924ab6ab172cd7a7e2531a10a2a7e62981 -2026-07-19-web-styling-system.zh.md: 01064d4d52b3ed2b179a4795f5113b94480945bd +2026-07-19-web-styling-system.zh.md: a22443aef8a4b72404b55bfce9cee60feb0b0364 diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md b/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md index 01064d4d52..a22443aef8 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md @@ -1,8 +1,8 @@ -# RFC: Web 样式体系——token 框架与工程约束 +# Agent Note: Web 样式体系——token 框架与工程约束 Status: implemented -> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威。 +> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、颜色只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威。 [English](2026-07-19-web-styling-system.md) | 中文 @@ -10,16 +10,16 @@ Status: implemented ## Problem -GUI 无设计师供给,样式由 agent 编写并 review;没有一套机器可对照的 token 体系与编码规范,颜色/圆角/动效会在组件间字面量漂移,暗色主题会长成组件内散落的条件分支。 +GUI 无设计师供给,样式由 agent 编写并 review;没有一套机器可检查的 token 体系与编码规范,颜色/圆角/动效会在组件间字面量漂移,暗色主题会长成组件内散落的条件分支。 ## Decision(框架五条) | # | 决策 | 内容 | |---|---|---| -| 1 | **视觉基线 = Chat 对齐** | 取值全部来自对 Chat 前端调研(品牌蓝 `--accent: #3964fe`、灰阶、气泡/侧边栏几何、阴影分级……);允许偏离但须在 web-styling.md 偏离表记录 | +| 1 | **视觉基线 = Chat 对齐** | 取值全部来自对 Chat 前端的调研(品牌蓝 `--accent: #3964fe`、灰阶、气泡/侧边栏几何、阴影分级……);允许偏离但须在 web-styling.md 偏离表记录 | | 2 | **token 两层不三层** | 基线仓是 static→alias→specific 三层;我们体量下压成「语义层直接持实值(注释标 base 色板出处)+ 极少数组件专属槽位(`--bg-sidebar`/`--bubble-bg`)」两层,全部住 `web-ui/src/style/global.css` | | 3 | **字号/间距不 token 化** | 基线仓同款决策:字号在组件里写 px 且**成对写行高**(16/24、14/22、12/18),间距用 4 的倍数;token 化只覆盖颜色/圆角/动效/字体栈/阴影 | -| 4 | **边框与交互态用透明度制** | 边框 `rgba(0,0,0,.04/.1)`、hover/active `rgba(38,49,72,.06/.1)`——叠加在任意海拔底色上都成立,不新造实色灰 | +| 4 | **边框与交互态用透明度制** | 边框 `rgba(0,0,0,.04/.1)`、hover/active `rgba(38,49,72,.06/.1)`——叠加在任意层级的背景色上都成立,不新造实色灰 | | 5 | **暗色只在 token 表做** | `:root` 亮色实值 + `[data-theme='dark']` 覆盖同名变量;**组件 CSS 零主题选择器**;确需按主题换非 token 值时用「CSS 变量桥」(组件定义局部变量、主题块只覆写变量) | ## 工程约束 @@ -45,13 +45,13 @@ GUI 无设计师供给,样式由 agent 编写并 review;没有一套机器 | 内容 | 归属 | |---|---| -| 框架五条、工程约束、为何两层/为何不 token 化字号 | 本 RFC(改=新 RFC 供替) | -| token 逐项权威值(含暗色)、视觉基线常数(侧边栏/气泡/会话列/输入卡片几何)、RPC 四象限方向符视觉词汇、编码规范 12 条、偏离记录 | web-styling.md(活文档,随实现演进) | +| 框架五条、工程约束、为何两层/为何不 token 化字号 | 本 RFC(修改框架须由新 RFC 取代本文) | +| token 逐项权威值(含暗色)、视觉基线常数(侧边栏/气泡/会话行/输入卡片几何)、RPC 四象限方向符视觉词汇、编码规范 12 条、偏离记录 | web-styling.md(活文档,随实现演进) | | 取值证据(deepseekchat file:line) | 调研归档已完成使命,git 历史留档 | ## Consequences -样式收敛到机器可对照:颜色/圆角/动效/阴影只引 web-styling.md §1 token,暗色是单一属性选择器覆盖表,review 与自查共用同一张 12 条清单。接受的代价:字号/间距靠成对行高与 4 倍数纪律而非 token;动框架本身须新 RFC 供替。 +样式收敛到机器可检查:颜色/圆角/动效/阴影只引 web-styling.md §1 token,暗色是单一属性选择器覆盖表,review 与自查共用同一张 12 条清单。接受的代价:字号/间距靠成对行高与 4 倍数纪律而非 token;动框架本身须由新 RFC 取代本文。 ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index deafb47f70..6189729de0 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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/process/2026-07-20-gui-testing-system.md 2026-07-20-gui-testing-system.md: 4a1600bbef7ef795677a446228fcc279a4b53f39 -2026-07-20-gui-testing-system.zh.md: 2aa5d7f66783c69964cabf7eb18a018b54528a33 +2026-07-20-gui-testing-system.zh.md: af4736ced6195dfc8d40ab276149490e8af94975 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 2aa5d7f667..af4736ced6 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -1,8 +1,8 @@ -# RFC: GUI 测试体系——三层结构 +# Agent Note: GUI 测试体系——三层结构 Status: implemented -> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。坑位归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。 +> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。slot 归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。 [English](2026-07-20-gui-testing-system.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 333d19da04..a1c49fdcae 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md 2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 0f324a5c235a2b495186caf0d6cc0490cc5876e3 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 4f5920ffc150ce16d5567ca05128f7524c2a2b21 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 0f324a5c23..4f5920ffc1 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -18,9 +18,9 @@ Status: implemented [CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 -每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 -该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 +该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 @@ -47,6 +47,6 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 仅在真实宿主内核或打包后的 Landlock 安装中可见的沙箱回归,可能在 master 上的运行报告前已经合并。我们接受这个合并后检测窗口,以换取从每个拉取请求中移除四个非阻塞作业;默认分支仍保留完整信号。 -明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 +明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每个方面都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 88d32203c0..8ccd5ca13e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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/process/2026-07-22-evidence-based-larger-hosted-runners.md 2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: e05ad30a713258ed7bc3d8099f8d6fab3d7c0c5d +2026-07-22-evidence-based-larger-hosted-runners.zh.md: f43712859d8351ffff45c7b6d5eb2b65015ee4c3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index e05ad30a71..f43712859d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -22,7 +22,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查以消费方通道的完整 project-reference 输出为输入。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 -产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包(package)启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 +产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -44,7 +44,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 -任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 +任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定版本的 payload 并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index e4cd3fd34f..d712a411b9 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.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/process/2026-07-22-fast-local-git-hooks.md 2026-07-22-fast-local-git-hooks.md: 838024c4293372b1430d357774feb06cd9742b9b -2026-07-22-fast-local-git-hooks.zh.md: 460acf5270c075a808c6a4dc42635a808c7cd192 +2026-07-22-fast-local-git-hooks.zh.md: 26968dd19ffb42f4a618dc760b2a6edeb4900393 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 460acf5270..26968dd19f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -8,7 +8,7 @@ Status: implemented agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。 -快速钩子仍需在工作离开本机之前拦下检查成本低且把握高的缺陷。暂存文件格式问题、空白错误、vendor 源码元数据缺失与仓库类型错误符合这条边界;单元测试套件、快照、文档检查、构建与包(package)的 `hygiene` 检查则随改动范围而异,不符合这条边界。 +快速钩子仍需在工作离开本机之前拦下检查成本低且把握高的缺陷。暂存文件格式问题、空白错误、vendor 源码元数据缺失与仓库类型错误符合这条边界;单元测试套件、快照、文档检查、构建与包的 `hygiene` 检查则随改动范围而异,不符合这条边界。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index 70c5ce5a90..1382e073c1 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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/implemented/process/2026-07-22-product-first-root-readme.md 2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e -2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84 +2026-07-22-product-first-root-readme.zh.md: 994228d89154a041757685f526cad63c9455cfc8 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index 1c4d5fa538..994228d891 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -16,7 +16,7 @@ Status: implemented 用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 -包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 +包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml index b27ba457af..e5d8f11c3c 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md 2026-07-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 -2026-07-23-personal-staging-maintenance-skills.zh.md: db1595c83da0ad93e9ba9055b5a3d7fe7cfe1706 +2026-07-23-personal-staging-maintenance-skills.zh.md: d988eae0b4746f14b893b8f1f32f8396e6548232 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md index db1595c83d..d988eae0b4 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 个人集成分支维护 skill(技能) +# Agent Note: 个人集成分支维护 skill Status: implemented @@ -10,13 +10,13 @@ Status: implemented ## 决策 -仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill 的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 +仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill(技能)的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 -升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩说明性差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前契约。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-<timestamp>` 同级克隆、本地 `dsh-upgrade/prepare-<timestamp>` 分支、新的 `dsh-staging/<timestamp>` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 +升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩文档差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前契约。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-<timestamp>` 同级克隆、本地 `dsh-upgrade/prepare-<timestamp>` 分支、新的 `dsh-staging/<timestamp>` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 -`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上完整应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 +`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上组装后应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 ## 备选方案 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 66131cfe0c..440691d218 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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/process/2026-07-23-portable-required-pull-request-ci.md 2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45 -2026-07-23-portable-required-pull-request-ci.zh.md: cf0af769f9e740a2c9285caf4be05023371578d9 +2026-07-23-portable-required-pull-request-ci.zh.md: f7a95ced2353ca7aaee9a586cc4f0bb297cbf7d4 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index cf0af769f9..f7a95ced23 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断表面([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单归 master 串行参考流程所有。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断性检查范围([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单位于 master 串行参考流程中。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml index 46f5d0e4ea..3d471fc9f3 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-translation-prompt-v4-contract.md: 3e1e51797aa3463c8db24d8657120434e6822789 -2026-07-23-translation-prompt-v4-contract.zh.md: 161d2b6cf3bd3499e3c505a178da40ce577ca797 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md +2026-07-23-translation-prompt-v4-contract.md: adeb63e0f4bf03ec0b94a371f596bb84138d81ed +2026-07-23-translation-prompt-v4-contract.zh.md: 5bd7bbf0d2d02222ca8038afb0f5a156fffea1b0 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md index 3e1e51797a..adeb63e0f4 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md @@ -12,6 +12,8 @@ Automated counterpart generation needs a stable prompt that reproduces the regis The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's binding pairing, terminology, structure, and emphasis contracts. +The v7 calibration retains that v4 protocol and makes the instruction priority explicit: source meaning and protected structure, then the terminology table, then whole-document gold-pair voice, then general guidance and embedded examples. It directs the model to draft as a native technical author and then compare clause by clause, preserving actors, conditions, negation, modality, lifecycle conditions, direction, result channels, ownership, and quantities. Style guidance cannot invent an actor or vary a terminology-table form, defined concept, or contract verb merely for variety. Unresolved terminology stays unchanged in the translation and is reported only as pending review. + The response has three ordered top-level sections: `translation`, `review`, and `final`. The response consumer derives the target basename from the retained source context, preserves optional leading YAML frontmatter, and mechanically inserts or corrects the language switcher after the first H1 in `final`. The parser requires each section exactly once, rejects content outside the envelope, and tolerates one outer `xml` Markdown fence because models sometimes echo the prompt's example fence. ## Response framing @@ -28,6 +30,10 @@ The executable contract lives in [the renderer, request assembler, parser, and r **Return only the final translation.** A single body is simpler to parse but discards the explicit correction pass used to catch tone, structure, terminology, and punctuation defects before publication. +**Replace the calibrated v4 asset wholesale with a later experimental prompt.** Later experiments clarified useful general rules but did not pass strict whole-document evaluation as complete replacements. The production asset adopts only the improvements that preserve the established examples and executable protocol. + +**Require a strict draft-to-final change ledger.** Requiring every final edit to appear in a free-text review ledger adds output burden without proving that the review catches local semantic drift. The review records actual corrections, while the final translation remains subject to deterministic structure checks and human review. + ## Consequences -Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with binding repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. +Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. Focused tests pin the retained v4 examples and selected v7 safeguards. The `translation-prompt-v4` snapshot directory names the stable renderer/parser protocol lineage rather than the current calibration revision. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with binding repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md index 161d2b6cf3..5bd7bbf0d2 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md @@ -12,6 +12,8 @@ Status: implemented 提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式契约。 +v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含义与受保护结构,再遵循术语表,然后以整篇金标校准语体,最后应用一般指导与内嵌示例。模型先以母语技术作者的方式起草,再逐项对照源文,保留执行主体、条件、否定、情态、生命周期条件、方向、结果通道、所有权和数量。文体指导不得虚构执行主体,也不得仅为丰富措辞而改换术语表词形、已定义概念或契约动词。无法裁定的术语在译文中保持不变,只在评审段标为待人工确认。 + 响应包含三个有序的顶层分段:`translation`、`review` 和 `final`。响应消费方根据保留的源文件上下文推导目标文件基本名,保留文件开头可选的 YAML frontmatter,并以机械方式在 `final` 中第一个 H1 之后插入或校正语言切换行。解析器要求每个分段恰好出现一次,拒绝封套之外的内容,并允许响应最外层有一层 `xml` Markdown 围栏,因为模型有时会照抄提示词中的示例围栏。 ## 响应封装格式 @@ -28,6 +30,10 @@ Status: implemented **只返回最终译文。** 单一正文更易解析,却会丢弃显式修正步骤;这个步骤用于在发布前发现语气、结构、术语和标点缺陷。 +**用较新的实验提示词整体替换经校准的 v4 资源。** 后续实验澄清了一些有价值的通用规则,但作为完整替代方案未通过严格的整篇文档评估。生产资源只吸收能够保留既有示例与可执行协议的改进。 + +**要求严格的草稿到定稿变更账本。** 要求定稿中的每项修改都出现在自由文本评审账本中,会增加输出负担,却不能证明评审能够发现局部语义漂移。评审段记录实际修正,定稿仍需接受确定性结构检查与人工评审。 + ## 影响 -提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 +提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。聚焦测试固定保留的 v4 示例与选定的 v7 保护规则。`translation-prompt-v4` 快照目录命名的是稳定的渲染器/解析器协议谱系,而不是当前校准修订号。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index e8fdb3f515..1aaa9f992b 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md 2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 -2026-07-25-semantic-pr-label-taxonomy.zh.md: 4cc603daa52bc9e6b0a85e33086a559a21dcc621 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 978f11af9402f248679e2087ff7fb513321b69b9 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index 4cc603daa5..978f11af94 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -44,7 +44,7 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 `tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 -标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。 +标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包产物,而不是文档生成器。 ### 可扩展性 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index 789f1ec6ea..be725fc905 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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/process/2026-07-26-briefed-minimal-translation-updates.md 2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77 -2026-07-26-briefed-minimal-translation-updates.zh.md: 5a6787b9bdb3286b71842abec075c5a0ebfc7991 +2026-07-26-briefed-minimal-translation-updates.zh.md: c3c1b4e845b5a45acde55884b883b1dd1e570d77 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index 5a6787b9bd..c3c1b4e845 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 基准测试 -该决策来自对本仓库历史上十次真实配对更新的受控回放(2026 年 7 月;每例改动 1 到 64 行英文,涵盖 README、RFC、Agent Note(agent 决策记录)与用户文档)。每个样例都在临时仓库中重建到其真实的上次确认状态,英文改动保持未提交,再用全新的 subagent 分别跑过相互竞争的各条工作流:维持现状的语料加载路径、简报路径、无指导对照组、整篇重译、小模型上的简报路径,以及每个 agent(智能体)一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。 +该决策来自对本仓库历史上十次真实配对更新的受控回放(2026 年 7 月;每例改动 1 到 64 行英文,涵盖 README、RFC、Agent Note 与用户文档)。每个样例都在临时仓库中重建到其真实的上次确认状态,英文改动保持未提交,再用全新的 subagent 分别跑过相互竞争的各条工作流:维持现状的语料加载路径、简报路径、无指导对照组、整篇重译、小模型上的简报路径,以及每个 agent(智能体)一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。 - 简报路径在盲评的忠实性、保留度与流畅度上与现状路径打平(两者都达到或超过真实历史更新的水平),而在未发生停滞的样例上只花费约三分之一的 token 用量与墙钟时间(全部十例的中位数:相对 token 成本单位 276k 对 595k,轮次数 14 对 32)。 - 整篇重译被证实有害,而不只是浪费:它丢弃经评审的措辞,盲评保留度因此崩塌(4.4/10 对 9.8);它还使各更新组保持住的既定术语发生漂移(既定译法本就写在对侧文件自身的正文里);而且它是成本最高的一组。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 5fb7527463..f4fe0c74d2 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md 2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 -2026-07-26-ci-failover-runbook.zh.md: fdce40ffac5036cb4caf8eb86d20b7bb5bae4fc8 +2026-07-26-ci-failover-runbook.zh.md: 1ee679eabc296ab31d71b87945409788539425bd diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index fdce40ffac..1ee679eabc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 @@ -20,11 +20,11 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏情况下,6 × 8 = 48 个覆盖率工作进程运行在 64 核虚拟机上)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 -#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 +#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 -**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 +**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 ## 切换期间的容量 @@ -47,4 +47,4 @@ Status: implemented ## 后果 -从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 +从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,避免故障切换目标变得陈旧;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.i18n.yaml index 4533e6dfe5..a15aaff72e 100644 --- a/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md 2026-07-26-dependencies-over-hand-rolling.md: 22720c483c1c9e8145497b3e83cbc9f17570b161 -2026-07-26-dependencies-over-hand-rolling.zh.md: ac988eb4b3af9ba18ee2150bab93f01f0e36003e +2026-07-26-dependencies-over-hand-rolling.zh.md: 4c3aee489cd8a8269b2b083ea2fb7978d01f4e26 diff --git a/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.zh.md b/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.zh.md index ac988eb4b3..4c3aee489c 100644 --- a/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.zh.md @@ -6,18 +6,18 @@ Status: implemented ## 问题 -harness 手写了大量基础设施,而成熟的外部包(package)早已提供同等能力。其中一部分是有意为之——以源码形式收录的 Cordis([引入 vendor 的决策](2026-06-11-vendor-cordis-as-source.md))、[孪生 LLM(大语言模型)适配器](../architecture/2026-06-13-twin-llm-adapters.md)、作为配置 schema 标准的 schemastery——但相当大一部分源自一条未经言明的「避免新依赖」反射,逐渐累积而成:仓库级的外部依赖清单始终很小,各包却各自长出了自己的 SSE(Server-Sent Events)解析器、协议分帧器、重试循环和 glob 匹配器。`AGENTS.md` 其实从未写下任何依赖政策,agent(智能体)只能从既有模式中自行推断出一条,而这条推断出的规则(「不要加依赖」)比任何人实际决定过的都更严格。这正是 Not Invented Here(非我发明)谬误在默认状态下运作:每一个对维护良好的库的手写克隆,都是要由我们自己测试、撰写文档、评审和调试的代码,却享受不到生态累积下来的边界情况修复。 +harness 手写了大量基础设施,而成熟的外部包早已提供同等能力。其中一部分是有意为之——以源码形式收录的 Cordis([引入 vendor 的决策](2026-06-11-vendor-cordis-as-source.md))、[孪生 LLM(大语言模型)适配器](../architecture/2026-06-13-twin-llm-adapters.md)、作为配置 schema 标准的 schemastery——但相当大一部分是在一种未经言明的「避免新依赖」下意识作用下逐渐累积而成的:仓库级的外部依赖清单始终很小,各包却各自长出了自己的 SSE(Server-Sent Events)解析器、协议分帧器、重试循环和 glob 匹配器。`AGENTS.md` 其实从未写下任何依赖政策,agent(智能体)只能从既有模式中自行推断出一条,而这条推断出的规则(「不要加依赖」)比任何人实际决定过的都更严格。这正是 Not Invented Here(非我发明)谬误在默认状态下运作:每一个对维护良好的库的手写克隆,都是要由我们自己测试、撰写文档、评审和调试的代码,却享受不到生态累积下来的边界情况修复。 ## 决策 -引入外部依赖是一种正当的简化,而不是政策特例。当一个维护良好的包(或我们引擎下限即已提供的 Node 内置能力)覆盖了某块手写接口面时,替换手写代码就是优先方向,并遵循与其他任何简化相同的证据标准:这次替换必须切实缩减我们持有的东西(代码、测试和契约面),而不是仅仅把复杂度挪到一个包装层后面。 +引入外部依赖是一种正当的简化,而不是政策特例。当一个维护良好的包(或我们引擎下限即已提供的 Node 内置能力)覆盖了某块手写接口面时,替换手写代码就是优先方向,并遵循与其他任何简化相同的证据标准:这次替换必须切实缩减我们负责维护的内容(代码、测试和契约面),而不是仅仅把复杂度挪到一个包装层后面。 新依赖的准入门槛: -- **净删除。** 该依赖替换的是真实持有的代码(实现 + 专属测试 + 文档),而不是假想中的未来代码。只增加能力的依赖属于功能决策,不属于简化。 +- **净删除。** 该依赖替换的是实际由我们维护的代码(实现 + 专属测试 + 文档),而不是假想中的未来代码。只增加能力的依赖属于功能决策,不属于简化。 - **健康度。** 持续维护、广泛使用、传递依赖足迹合理。一个无人维护的小包,只是拿我们的代码换来别人废弃的代码。 - **边界契合。** 该包的语义要覆盖我们的实际契约;仍需围绕它手写补齐的残留语义,要计入这次替换的减分项。 -- **不触碰已定案的 seam。** schemastery(配置 schema)、源码收录的 Cordis、`@earendil-works` 孪生适配器,以及其他记录在已实现 Agent Note(agent 决策记录)中的决策,不因本政策而重开;一次会瓦解已记录设计的替换,必须胜过所记录的论证理由,而不能只援引本 Agent Note。 +- **不触碰已定案的 seam。** schemastery(配置 schema)、源码收录的 Cordis、`@earendil-works` 孪生适配器,以及其他记录在已实现 Agent Note 中的决策,不因本政策而重开;一次会瓦解已记录设计的替换,必须胜过所记录的论证理由,而不能只援引本 Agent Note。 `packages/util/` 的「零依赖」章程描述的是该分组的*导出*纪律(util 包不携带 harness 依赖,从而任何分组都能依赖它们),并不禁止在能带来简化时使用外部包;如果一个 util 包的全部职责有维护良好的外部包做得更好,就应当用该依赖替换它,而不是为了章程而保留它。 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index b7e099dec5..b5e11d7a71 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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/process/2026-07-26-frozen-agent-note-archive.md 2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 -2026-07-26-frozen-agent-note-archive.zh.md: 9362fbcb268045f0cb6762bf6804a54eed34caee +2026-07-26-frozen-agent-note-archive.zh.md: ac87935fff1b3942d0bc8eade49237aa3c90d21e diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index 9362fbcb26..ac87935fff 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -implemented Agent Note(agent 决策记录)作为当前决策记录持续维护,因此活跃记录集合中的每个路径、符号、默认值、译文、代码围栏、包(package)引用和出站链接都会形成维护义务。当决策依据可以指导未来工作时,这项成本合理;但对于已经收尾的 UI 细节、小型修复、已被取代的实现机制,或当前权威依据已转移到别处的流程历史,这项成本并不值得。删除所有低价值的已实施记录会抹去有用的历史证据,而保留每一项被否决的提案,又会留下既无采纳可能也无启发意义的想法。这套记录集合需要一道留存边界,在区分活跃指导与冻结历史的同时,避免让归档成为另一个维护层级。 +implemented Agent Note 作为当前决策记录持续维护,因此活跃记录集合中的每个路径、符号、默认值、译文、代码围栏、包引用和出站链接都会形成维护义务。当决策依据可以指导未来工作时,这项成本合理;但对于已经收尾的 UI 细节、小型修复、已被取代的实现机制,或当前权威依据已转移到别处的流程历史,这项成本并不值得。删除所有低价值的已实施记录会抹去有用的历史证据,而保留每一项被否决的提案,又会留下既无采纳可能也无启发意义的想法。这套记录集合需要一道留存边界,在区分活跃指导与冻结历史的同时,避免让归档成为另一个维护层级。 ## 决策 -只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久化语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 +只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 -归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随文件,以及机械修复入站链接。 +归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档契约而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。 -[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 +[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单) 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 7ad4d7bee7..7005f08ce0 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md 2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2258a1fe5f91b27b8a3b636c7f3ad26f502a98df diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index d511e7a2b8..2258a1fe5f 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,22 +13,22 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 - **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 - **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 - **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 -- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 -- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一端——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **只转换带缓存的工作流,留下其余出现 `corepack enable` 的位置。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 - **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。 -- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 +- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么不得不增加与这些差异一一对应的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 ## 后果 - corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 - generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 -- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 +- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是利用上一条缓存记录预填充。 - `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml index 72cbbc368f..1aab8e5bad 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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/process/2026-07-26-web-syntax-highlighting-shiki.md 2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687 -2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050 +2026-07-26-web-syntax-highlighting-shiki.zh.md: 31fb3d916c7cb1e928bc37eca86fb11e21374b16 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md index 780b66a309..31fb3d916c 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -1,10 +1,10 @@ -# Agent Note:web client 的语法高亮——同步细粒度的 shiki +# Agent Note: web client 的语法高亮——同步细粒度的 shiki Status: implemented [English](2026-07-26-web-syntax-highlighting-shiki.md) | 中文 -> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表契约与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则归 [Web 样式体系裁决](2026-07-19-web-styling-system.md)所有。 +> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表契约与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则由 [Web 样式体系裁决](2026-07-19-web-styling-system.md)规定。 ## 问题 @@ -15,8 +15,8 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围 **采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。** - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。 -- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。 -- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。 +- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息字符串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。 +- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何颜色字面量都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。 - **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 961eeb8f0d..07c742c518 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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/process/2026-07-27-dependabot-version-updates.md 2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 -2026-07-27-dependabot-version-updates.zh.md: 1ab34e76b84c7423be76fa79ae6bb3705a07a76c +2026-07-27-dependabot-version-updates.zh.md: 6400ba8ed94bf138fcece90e5d7ff82886d33ed1 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 1ab34e76b8..6400ba8ed9 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包(package)树。 +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包树。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index b7072336ea..b55d583430 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.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/process/2026-07-27-explicit-change-scope-report.md 2026-07-27-explicit-change-scope-report.md: ed09ffc44252e1e571d50537b3471cad8d68d8f9 -2026-07-27-explicit-change-scope-report.zh.md: e13092395d6708b1ec030d13365853a5eaecba55 +2026-07-27-explicit-change-scope-report.zh.md: 783f89b9b42f1706da218997a06bfe7c28a0936b diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index e13092395d..783f89b9b4 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -32,10 +32,10 @@ Status: implemented **报告当前分支与上游,并维护并行的人类可读渲染器。** 调用方在调用前已经验证分支和基准状态,没有消费方使用这些字段,而格式化文字只会重复 JSON schema,并不能提高路径完整性。 -## 结果 +## 后果 显式输入仍可能指定错误的基准,但这种错误是可见的:报告中会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。 -仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。 +仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份关于已提交变更和本地变更的确定性只读说明,而不必混入代码托管平台或策略职责。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index b8d12ccea3..4786909a7f 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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/process/2026-07-27-wine-windows-gates-experiment.md 2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc -2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1 +2026-07-27-wine-windows-gates-experiment.zh.md: 67f59a93d1b1fad98e36e6f9c51bc77abb9e3d07 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index f30e09ca74..67f59a93d1 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -6,42 +6,42 @@ Status: implemented ## 问题 -Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——它此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:7–9 分钟,对照 Linux 作业的 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。 +Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 workspace 构建与生产站点。该通道此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:耗时 7–9 分钟,而 Linux 作业耗时 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。 -实验回答的问题是:一台普通 Linux runner 能否以 Linux 墙钟为阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM? +实验回答的问题是:一台普通 Linux runner 能否以 Linux 作业的墙钟时间为这些阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM? ## 决策 [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 -该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 +该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 -门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把已跟踪文件和未跟踪但未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 ## 实测结果 -2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准腿,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。 +2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准测试作业,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。 ## 考虑过的替代方案 **保留托管 `windows-2025` 的 pull request 作业(现状)。** 其信号没有问题,问题只在延迟:为两条构建命令花 7–9 分钟,是必需矩阵中最慢的作业。它作为 master 串行参照存续——在那里完整性比延迟更重要。 -**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可晋升。 +**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 -**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 +**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 **Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。 **砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。 -## 结果 +## 后果 -每个 pull request 的 Windows 裁决现在以 Linux 作业的时间在免费标准容量上到达,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。 +每个 pull request 的 Windows 裁决现在都能在 Linux 作业的耗时范围内、利用免费的标准 runner 容量得出,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。 -这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上的构建包不变量)完全不再于 pull request 上运行。master 的 `serial-windows` 参照拥有这一切:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,该失败模式被接受为合并后处理。该通道还把 Wine 特有的分歧继承为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。 +这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上已构建包的不变量)完全不再于 pull request 上运行。这一切均由 master 的 `serial-windows` 参照负责验证:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,项目接受这种失败可能在合并后才出现。该通道还将 Wine 特有的差异固化为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。 diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index 4eb85b7d70..4dee769645 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md 2026-07-27-worktree-local-lefthook.md: 75dfd47087356c34005ec4673e174e451a72c660 -2026-07-27-worktree-local-lefthook.zh.md: bc4902769561c3d33d2101de55e28e70d114f39b +2026-07-27-worktree-local-lefthook.zh.md: 42d4d89e375dfd7b28298d89e82c5e9e74908062 diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index bc49027695..42d4d89e37 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -28,7 +28,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 **构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 -**将特定 CI 提供商的凭据 include 路径加入白名单。** CI 不使用贡献者钩子,因此路径豁免会使安装程序的安全性耦合于提供商的检出目录内部结构,并削弱贡献者安装时的严格验证。CI 无操作方案无需任何豁免即可避免修改仓库。 +**将特定 CI 提供商的凭据 include 路径加入白名单。**CI 不使用贡献者钩子,因此路径豁免会使安装程序的安全性耦合于 CI 提供商检出流程的内部实现,并削弱贡献者安装时的严格验证。在 CI 中直接跳过操作,无需任何豁免即可避免修改仓库。 **停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml index bb5a947cfd..ae7c705d5d 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.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/process/2026-07-29-oxlint-linter.md 2026-07-29-oxlint-linter.md: 41a50a9d08819809f954aa99007081f270692f38 -2026-07-29-oxlint-linter.zh.md: 1ad72a00cb921ed688363583d56634f52b355b4e +2026-07-29-oxlint-linter.zh.md: 85d053af867353ff1b4c53822013f55660c89cca diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md index 1ad72a00cb..85d053af86 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包(package)脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint,进行全仓库、类型感知或暂存验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 +根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint,进行全仓库、类型感知或暂存验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 `options.typeAware` 启用 `oxlint-tsgolint`。其后端按文件发现 TypeScript 项目:包源码使用各自的包项目,host 测试、示例和网站使用 `tsconfig.host.json`,client 测试及 `scripts/client-bundle-purity.spec.ts` 使用 `tsconfig.client.json`。不含程序的根解决方案绝不会被扁平化。Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。 diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml index 9b7308098b..58d73d72b2 100644 --- a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-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/process/2026-07-30-cordis-config-source-plane-resolution-gate.md 2026-07-30-cordis-config-source-plane-resolution-gate.md: f9070d39559948ef27f96df5afccd7c4e076f131 -2026-07-30-cordis-config-source-plane-resolution-gate.zh.md: fac6c4047d334d7dd0685aa270234fee3d15dba8 +2026-07-30-cordis-config-source-plane-resolution-gate.zh.md: eb8fa6fa77f2b363214c5a67af0cd9ce3409b721 diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md index fac6c4047d..eb8fa6fa77 100644 --- a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`apps/cli/config/tui.cordis.yml` 新增了 `@deepseek-ai/dsh-tui/prompt` 配置项,却没有对应的 tsconfig `paths` 映射。通用的 `@deepseek-ai/dsh-*` 通配符会把 `tui/prompt` 整体代入其 `<group>/*/src` 候选路径,而这些路径全都不存在,因此 [tsx 源码启动](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)会回退到包(package)的 `exports`,解析出产物面文件 `lib/prompt.js`。任何带有已构建 `lib/` 的环境(开发者目录树运行 `pnpm build` 后)都能正常启动,而 e2e 工作流以 `lib` 模式(`DSH_EXAMPLE_MODE=lib`,构建产物 bin 在普通 Node 下运行)执行无密钥 TUI PTY 冒烟测试,因此 CI 根本不会经过源码启动向量——与此同时,所有干净检出环境中的 `pnpm dsh` 都会在启动时失败,并报错 `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`。当时没有门禁检查源码面,因此该故障未被发现便进入发布版本,仅在新的 worktree 中暴露。 +`apps/cli/config/tui.cordis.yml` 新增了 `@deepseek-ai/dsh-tui/prompt` 配置项,却没有对应的 tsconfig `paths` 映射。通用的 `@deepseek-ai/dsh-*` 通配符会把 `tui/prompt` 整体代入其 `<group>/*/src` 候选路径,而这些路径全都不存在,因此 [tsx 源码启动](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)会回退到包的 `exports`,解析出产物面文件 `lib/prompt.js`。任何带有已构建 `lib/` 的环境(开发者目录树运行 `pnpm build` 后)都能正常启动,而 e2e 工作流以 `lib` 模式(`DSH_EXAMPLE_MODE=lib`,构建产物 bin 在普通 Node 下运行)执行无密钥 TUI PTY 冒烟测试,因此 CI 根本不会经过源码启动向量——与此同时,所有干净检出环境中的 `pnpm dsh` 都会在启动时失败,并报错 `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`。当时没有门禁检查源码面,因此该故障未被发现便进入发布版本,仅在新的 worktree 中暴露。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml new file mode 100644 index 0000000000..7f9a5eee29 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md +2026-08-06-coverage-uncovered-locations.md: 27c466e7ae505a2d318f8b21d442c31619610f59 +2026-08-06-coverage-uncovered-locations.zh.md: 93eeb863e51679c60d9e0ccfc742156c098aeca4 diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md new file mode 100644 index 0000000000..27c466e7ae --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md @@ -0,0 +1,42 @@ +# Agent Note: Exact uncovered locations on coverage failure + +Status: implemented + +English | [中文](2026-08-06-coverage-uncovered-locations.zh.md) + +## Problem + +When the per-file 100% coverage gate fails, vitest emits only file-level error lines (`ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>`) — you learn which file fell short, not which lines. The built-in `text` report does have an Uncovered Line #s column, but it is one giant table over hundreds of files repo-wide: the column truncates at the table width, carries line numbers but no column numbers, does not distinguish statements from branches from functions, and passing files occupy rows all the same. The net effect is that a red coverage run on CI is not directly actionable; the only way to locate the specific gap is to rerun the html report locally. + +## Decision + +`scripts/coverage-uncovered-locations.cjs` is a custom istanbul reporter (a `ReportBase` subclass): for every file below 100%, it emits one self-contained single-line record per uncovered statement, untaken branch path, and uncalled function — `<path>:<line>:<col> uncovered <kind> …` — directly clickable in terminals and CI logs, and easy to grep. When every file passes, it prints nothing. istanbul report generation runs before threshold validation, so the records land exactly above the existing ERROR lines. + +The wiring is a single point: the coverage block in the root `vitest.config.ts` is the repo's only coverage configuration, shared by the CI lane (`run-gates ci-coverage`), local `test:coverage`, and focused runs (`--coverage.include`). The reporter joins both the CI and local reporter arrays by absolute path (`fileURLToPath`) — istanbul-reports' `create()` falls back to a bare `require(name)` for non-built-in names, and a relative path would resolve against istanbul's own package directory. + +Output conventions: + +- istanbul's 0-based column numbers are converted to 1-based (the convention editors and terminal links expect). +- v8 reports `end.column = Infinity` for whole-line statements: a span crossing lines degrades to a `(to <line>)` suffix carrying only the line number, and a single-line span omits the suffix. +- An implicit branch arm (such as a missing else) may carry no location; the reporter falls back to the branch's own span so the record stays clickable; branch records are annotated with the branch type and `path k/n`. +- Records within a file are sorted by line, then column; there is no cap on the count. + +Two companion changes: the root `package.json` adds `istanbul-lib-report` as a devDependency (under pnpm's strict layout, `scripts/` cannot reach nested dependencies); the root workspace's entry/project globs in `knip.json` gain `scripts/**/*.cjs`, making the file and its dependencies visible to the hygiene gate. + +CJS is a forced shape, and a justified exception to the ESM-everywhere discipline: istanbul loads custom reporters via a bare `require()` outside the tsx/Vite pipeline, where TypeScript cannot participate; the namespace object `require(esm)` returns also fails its `new Cons(cfg)` construction — CommonJS is the only reliable shape. + +## Alternatives considered + +- **Rely on the built-in `text` report's Uncovered Line #s column.** This is precisely the problem as found: one repo-wide table, column-width truncation, line numbers only, no kind distinction, passing files in the same column — not actionable in CI logs. +- **Add a `json` reporter plus a separate wrapper script that reads `coverage-final.json` for post-processing after a failure.** Feasible in pure ESM/TS, but the wrapper would have to wrap both entry points — `package.json`'s `test:coverage` and the run-gates gate — changing their command shapes; the custom-reporter route touches one piece of configuration and takes effect at both entry points automatically. +- **Write the reporter in TypeScript/ESM.** istanbul's loading mechanism (a bare `require` outside the pipeline) rules this out, as above; swapping out the loading mechanism for the sake of one report file is out of proportion. + +## Verification + +Local matrix: with a deliberately induced failure, all three record kinds appear and their locations match the planted gaps; a mixed run emits records only for the failing files (files at 100% within the same run stay silent); an all-green run produces zero output and exit code 0. CI evidence: after temporarily planting one unreachable statement/branch/function in `clampTimeout`, the coverage lane — under the isolated condition of all tests passing (632 files / 10326 cases) with only the threshold failing — printed the 4 records above the ERROR lines (evidence links in the PR #1716 description); the demonstration commit was removed from the branch before merge. + +## Consequences + +- A red coverage run is self-sufficient: the log gives exact line and column numbers plus the kind of each gap, and rerunning the html report locally to pinpoint it is no longer needed. +- The cost is one CJS-file discipline exception and one root devDependency; all-green runs produce zero output and add no log noise. +- A file with zero coverage yields output on the order of its statement count (deliberately uncapped): the gate demands zero gaps, so the full listing is the action list, and vitest's own ERROR lines already provide the per-file summary as a backstop. diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md new file mode 100644 index 0000000000..93eeb863e5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 覆盖率未达标时输出精确未覆盖位置 + +Status: implemented + +[English](2026-08-06-coverage-uncovered-locations.md) | 中文 + +## Problem + +per-file 100% 覆盖率门禁失败时,vitest 只输出文件级错误行(`ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>`)——知道哪个文件没达标,不知道差在哪几行。内置 `text` 报表虽有 Uncovered Line #s 列,但它是全仓几百个文件的大表:该列按表宽截断、只有行号没有列号、不区分语句/分支/函数,且达标文件同样占行。结果是 CI 上的覆盖率红报不可直接行动,定位具体缺口只能本地重跑一遍 html 报表。 + +## Decision + +`scripts/coverage-uncovered-locations.cjs` 是一个自定义 istanbul reporter(`ReportBase` 子类):对每个低于 100% 的文件,按未覆盖语句、未走的分支路径、未调用函数各输出一条自含的单行记录 `<path>:<line>:<col> uncovered <kind> …`——terminal 与 CI 日志中可直接点击跳转,也便于 grep。全部文件达标时零输出。istanbul 报表生成先于 threshold 校验,因此记录恰好落在既有 ERROR 行上方。 + +接线是单点的:根 `vitest.config.ts` 的 coverage 块是全仓唯一覆盖率配置,CI lane(`run-gates ci-coverage`)、本地 `test:coverage` 与聚焦跑(`--coverage.include`)共用它。该 reporter 以绝对路径(`fileURLToPath`)加入 CI 与本地两个 reporter 数组——istanbul-reports 的 `create()` 对非内置名回退为裸 `require(name)`,相对路径会按 istanbul 自己的包目录解析。 + +输出约定: + +- istanbul 的 0 基列号转为 1 基(编辑器与终端链接的约定)。 +- v8 对整行语句给出 `end.column = Infinity`:跨行时降级为只带行号的 `(to <line>)` 后缀,单行时省略后缀。 +- 隐式分支臂(如缺省 else)可能不带位置,回退到分支自身的 span,保证记录仍可点击;分支记录标注类型与 `path k/n`。 +- 同文件内记录按行、列排序;不设条数上限。 + +配套两处:根 `package.json` 增补 devDependency `istanbul-lib-report`(pnpm 严格布局下 `scripts/` 摸不到嵌套依赖);`knip.json` 根 workspace 的 entry/project 通配增加 `scripts/**/*.cjs`,使该文件及其依赖对 hygiene 门禁可见。 + +CJS 是被迫的形态,也是 ESM-everywhere 纪律的一个有据例外:istanbul 在 tsx/Vite 管线之外用裸 `require()` 装载自定义 reporter,TypeScript 无法参与;`require(esm)` 返回的命名空间对象也过不了它的 `new Cons(cfg)` 构造,CommonJS 是唯一可靠形态。 + +## Alternatives considered + +- **依赖内置 `text` 报表的 Uncovered Line #s 列。** 正是问题现状:全仓大表、列宽截断、只有行号、不分种类、达标文件同列——CI 日志里不可行动。 +- **加 `json` reporter,另写 wrapper 脚本失败后读 `coverage-final.json` 后处理。** 纯 ESM/TS 可行,但 wrapper 必须同时包住 `package.json` 的 `test:coverage` 与 run-gates 的 gate 两个入口,命令形状随之改变;自定义 reporter 路线只动一处配置,两个入口自动生效。 +- **用 TypeScript/ESM 写 reporter。** istanbul 的装载机制(管线外裸 `require`)决定了不可行,见上;为一个报表文件把装载机制换掉不成比例。 + +## Verification + +本地矩阵:故意制造未达标时三类记录齐全、位置与埋点一致;混合现场只输出未达标文件(同跑内 100% 的文件静默);全绿跑零输出、退出码 0。CI 实证:临时在 `clampTimeout` 埋入一处不可达语句/分支/函数后,coverage lane 在全部测试通过(632 文件 / 10326 用例)、仅 threshold 失败的隔离条件下,把 4 条记录打印在 ERROR 行上方(证据链接见 PR #1716 描述);演示提交在合并前已从分支撤除。 + +## Consequences + +- 覆盖率红报自足:日志直接给出精确行列号与缺口种类,不再需要本地重跑 html 报表定位。 +- 代价是一个 CJS 文件的纪律例外与一个根 devDependency;全绿运行零输出,不增加日志噪音。 +- 整文件零覆盖时输出条数与该文件语句数同阶(刻意不设上限):门禁要求零缺口,全量列出即是行动清单,vitest 自身的 ERROR 行已按文件汇总兜底。 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index ec8fddb9f6..ee84bcf366 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md 2026-06-19-drop-mutable-session-summary.md: f87378a1c3737950eb536be8e3f6776586eb8a01 -2026-06-19-drop-mutable-session-summary.zh.md: 05b6711b71ef87602b46706cd4340a10453e66ad +2026-06-19-drop-mutable-session-summary.zh.md: 41dab3fa0b487b057d33335a21cee92cf211560f diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index 05b6711b71..41dab3fa0b 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -[会话持久化 seam](../architecture/2026-06-14-session-persistence.md)将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「可在不触碰仅追加日志的情况下更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力保证);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 +[会话持久化 seam](../architecture/2026-06-14-session-persistence.md)将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「可在不触碰仅追加日志的情况下更新」。二者合并为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁通过临时写入后 rename,以尽力而为的方式原子发布一个独立的 `.summary.json` **伴随文件**;SQLite 则使用 `updated_at`/`title`/`first_prompt` **列**,并在追加事务内更新其中的时间列。 -摘要是为未来的会话选择器设计的(通过 `updatedAt` 排序近期会话,用 `title`/`firstPrompt` 做预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的全部表面积都是**死状态**: +摘要是为未来的会话选择器设计的(通过 `updatedAt` 排序近期会话,用 `title`/`firstPrompt` 做预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的整套相关接口都只是在维护**无用状态**: - `SessionPersistence.update()` **零个生产调用方**(所有 `.update(` 匹配都是 `createHash().update()` 或测试代码)。 - `firstPrompt` 在生产代码中**从未被读取**。 - 会话标题来自持久的 `session/title` 事件,工具卡片标题来自工具 presenter;二者都不读取可变的会话元数据。 -- 持久化列表的消费方使用不可变 header 中的标识、创建、谱系和 cwd 字段。近期排序和预览派生自日志,而非某个 `updatedAt` 摘要。 +- 持久化列表的消费方使用不可变 header 中的标识、创建时间、谱系和 cwd 字段。近期排序和预览派生自日志,而非某个 `updatedAt` 摘要。 - 决定性的一点:活跃的 `Session.header` 类型本来就是 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试外无人写入、无人读取。 ## 决策 @@ -22,14 +22,14 @@ Status: implemented 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -这被记录为一项决策,因为它具有**持久性**(它同时收窄两个后端的公共服务契约和磁盘格式)、**争议性**(summary 是有意为未来设计的结果,而非意外),也具有**意外性**(未来读者在原 Agent Note(agent 决策记录)描述 `SessionMeta` 的位置发现 `SessionHeader`,否则会追问 summary 为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清障碍:不再有可变 summary 后,协调器的钩子接口不需要 `updateSummary` 钩子,JSONL sidecar 与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 +这被记录为一项决策,因为它具有**持久性**(它同时收窄两个后端的公开服务契约和磁盘格式)、**争议性**(summary 是有意为未来设计的结果,而非意外),也具有**反直觉性**(未来读者在原 Agent Note 描述 `SessionMeta` 的位置发现 `SessionHeader` 时,若无此记录便会追问 summary 为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清障碍:不再有可变 summary 后,协调器的钩子接口不需要 `updateSummary` 钩子,JSONL sidecar 与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 ## 无需迁移 -这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧*还是*更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集下被半读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 +这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论版本更旧*还是*更高,因此陈旧的 v1 数据库会被干净地拒绝,而不会按新的列集合进行不完整读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 ## 后果 -未来的会话选择器现在必须从日志派生预览/排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个尚不存在的功能维护缓存,是每个后端都要付出维护成本、每个契约测试都要付出断言成本的死重。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 +未来的会话选择器现在必须从日志派生预览/排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个尚不存在的功能维护缓存,是每个后端都要承担维护成本、每个契约测试都要承担断言成本的无谓负担。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index b40c734b2c..c8714bf8ab 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md 2026-06-20-collapse-trace-only-session-events.md: c77062c3cd44286b43c175702c35b36f9cc31da6 -2026-06-20-collapse-trace-only-session-events.zh.md: b232b3fb60822e60b1f5767066db42b0228269a8 +2026-06-20-collapse-trace-only-session-events.zh.md: bc7e33f2d5dce370b846b1451fca6a652660dc92 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index b232b3fb60..bc7e33f2d5 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将仅用于追踪的会话事实折叠进承载性事件 +# Agent Note: 将仅用于追踪的会话事实折叠进承载实际功能的事件 Status: implemented @@ -8,11 +8,11 @@ Status: implemented 会话事件词汇中包含一些一等事件,它们不属于可回放的对话历史,在生产环境中几乎没有消费方。`usage` 已经作为模型流分片存在,之后循环又追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取轮次结束原因,而消息投影和 UI 投影都会跳过独立的 `error` 事件。 -这些事件让规范的 transcript(文本记录)看起来比实际更像遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际功能。它们携带的事实仍然有用:token 用量应当保留以供计费,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本已必须理解的邻近事件,而非减少记录的信息量。 +这些事件让规范的 transcript(文本记录)看起来比实际更适合作为遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际功能。它们携带的事实仍然有用:token 用量应当保留以供计费,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本已必须理解的邻近事件,而非减少记录的信息量。 ## 决策 -仅在信息已被保留、无需并行记录的情况下,移除独立的追踪事件: +仅在信息已被保留、无需并行记录的情况下,移除独立的、仅用于追踪的事件: - 成功步骤的 usage 折叠进匹配的 `assistant/message`(`assistant/message { turn, step, content, usage? }`),使组装好的模型输出与其计费信息一同传递。 - 失败或中止的步骤如果有 usage 但没有 assistant 内容,则将 usage 放在一个空内容的 `assistant/message` 上(下方实现说明给出了无信息丢失的证明)——不会有已持久化的 usage 分片无处安放。 @@ -23,22 +23,22 @@ Status: implemented ## 曾考虑的替代方案 -**保留独立行作为遥测**——这些事件让规范 transcript 看起来比实际更像遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有任何消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非对话日志中的重复追踪行。 +**保留独立行作为遥测**——这些事件让规范 transcript 看起来比实际更适合作为遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有任何消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非对话日志中的重复追踪行。 ## 验证 -`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,持久性失败通过 `turn/end { kind: 'error', step, message, code? }` 记录;ACP 快照和持久化测试断言不存在仅追踪行;已录制的 fixture(测试前置数据)使用新事件形状,会话格式版本固定为 `0`(后端按预发布格式策略拒绝任何非 `0` 的存储日志);文档说明了 token 用量和操作错误的观测位置。 +`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 `turn/end { kind: 'error', step, message, code? }` 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;已录制的 fixture(测试前置数据)使用新事件形状,会话格式版本固定为 `0`(后端按预发布格式策略拒绝任何版本非 `0` 的已存储日志);文档说明了 token 用量和运行时错误的观测位置。 ## 后果 -消费方不能再从规范日志中筛选独立的 `usage` 或步骤级 `error` 行,必须从承载它们的 assistant/failure 事件中读取这些事实。只有在实现 PR(Pull Request)证明相同事实仍然存在的前提下,这才是合理的简化;否则独立事件应予保留。 +消费方不能再从规范日志中筛选独立的 `usage` 或步骤级 `error` 行,而必须从承载这些信息的助手消息或失败事件中读取这些事实。只有在实现 PR(Pull Request)证明相同事实仍然存在的前提下,这才是合理的简化;否则独立事件应予保留。 ## 实现说明 -按提案落地,但有一处范围细化(遵循 AGENTS.md 所述“Agent Note(agent 决策记录)是提案,而非绝对真理”): +按提案落地,但有一处范围细化(遵循 AGENTS.md 所述「Agent Note 是提案,而非绝对真理」): -- **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向提供方 transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍被表示,且派生历史未被破坏。 +- **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向提供方 transcript 注入一个多余的无内容 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍有记录,且派生历史未被破坏。 **格式版本。** 此变更影响已持久化的事件,但预发布会话格式仍固定为 `0`,拒绝任何其他版本且不做迁移。`dsh-session` 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。 -Usage 现在通过 `assistant/message.usage` 观测;操作错误的步骤编号通过 `turn/end.reason`(当 `kind: 'error'` 时)观测。`agent/error` 与日志用于实时诊断,保持不变。 +Usage 现在通过 `assistant/message.usage` 观测;运行时错误的步骤编号通过 `turn/end.reason`(当 `kind: 'error'` 时)观测。`agent/error` 与日志用于实时诊断,保持不变。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index 86f2224878..451ffb98ca 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md -2026-06-20-public-agent-stop-surface.md: 7e8f6f691c999fd78c9b4133eaeac40f2d1c1ba9 -2026-06-20-public-agent-stop-surface.zh.md: 041d666eb817450add2d7a1746f7f5b81223568a +2026-06-20-public-agent-stop-surface.md: e9c713c7ba8c784d3d66fe8f391e1b197f7394d2 +2026-06-20-public-agent-stop-surface.zh.md: 5c1eb0ec8245c60613d6d4d7c1d105b5243bc938 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md index 7e8f6f691c..e9c713c7ba 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -36,4 +36,4 @@ A plugin can abort the active turn while preserving queued prompts through `keep ## Related -This Agent Note only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, `whenIdle()`, status, options, session, and identity. +This Agent Note only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting delivery surface is `followup()`, `steer()`, and `inject()`; stopping and observation remain with `cancel()` and `whenIdle()`. diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 041d666eb8..5c1eb0ec82 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -10,7 +10,7 @@ Status: implemented 公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对步骤的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者原本只暴露广义默认行为,该行为会清除已排队和 steering(中途引导)工作,同时中止活动轮次。`cancel(cause, { keepInbox: true })` 现在无需暴露私有轮次 holder 即可覆盖生产环境的 Web 停止策略;ACP 保留广义取消,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对步骤的 abort。 -行为差异确实存在,但已发布代码不需要独立的更窄动词。AgentLoop 为整个轮次拥有一个私有取消 holder。`cancel(cause, options?)` 携带显式且类型化的 `user` 或 `parent` 原因;其广义默认行为丢弃待处理输入,`keepInbox` 则为后续轮次保留待处理工作。资源释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式轮次取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 +行为差异确实存在,但已发布代码不需要独立的更窄动词。AgentLoop 为整个轮次拥有一个私有取消 holder。`cancel(cause, options?)` 携带显式且类型化的 `user` 或 `parent` 原因;其广义默认行为丢弃待处理输入,`keepInbox` 则为后续轮次保留待处理工作。资源释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式轮次取消 Agent Note](../architecture/2026-07-16-explicit-turn-cancellation.md)。 多余的公开接口使循环承载了一个本质上属于内部拆卸的公开动词。带选项的 `cancel()` 可以表达调用方策略,而无需暴露第二个 holder 形态的操作。 @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;完全停稳观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、会话和 identity。 +本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;完全停稳观察仍通过 `whenIdle()` 完成。最终交付接口包括 `followup()`、`steer()` 和 `inject()`;停止与观察仍通过 `cancel()` 和 `whenIdle()` 完成。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 60aaedb22f..1357d372f5 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-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/simplification/2026-06-20-remove-agent-boundary-mirror-events.md 2026-06-20-remove-agent-boundary-mirror-events.md: 24ac1e32667eb325044f4df583cde509421af3de -2026-06-20-remove-agent-boundary-mirror-events.zh.md: c7bf4fe9cc9ff50deb99527530ee0256fca10cb3 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 633c349a3e3a1efc4089fa9ae639aa457dd3d619 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index c7bf4fe9cc..633c349a3e 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -7,7 +7,7 @@ Status: implemented <!-- 以修订、收窄后的形式落地: 移除了四个轮次/步骤边界镜像;此处保留了 `agent/steering` 和 `agent/stream-chunk`(它们不是持久边界镜像——参见 - “范围:移除什么、不移除什么”)。原始提案将 `agent/steering` 与其他项一并 + 「范围:移除什么、不移除什么」)。原始提案将 `agent/steering` 与其他项一并 移除;把它排除在外,使本 Agent Note 的范围保持在边界上。后来每个保留事件 都由各自的决策移除——参见 [停止将 token 流镜像为 agent 事件](../../archived/simplification/2026-07-02-remove-stream-chunk-mirror.md) @@ -15,17 +15,17 @@ Status: implemented ## 问题 -循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个真源之间做选择。ACP(Agent Client Protocol)已经为提示词结算和已提交输出选择会话日志,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个真源之间做选择。ACP(Agent Client Protocol)已经为提示词结算和已提交输出选择会话日志,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产环境消费方;它已经从 `session/event` 渲染工具调用和结果。 -这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 +这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败事件的先后关系变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 ## 决策 -将 `session/event` 作为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇中派生 UI。 +将 `session/event` 作为唯一的实时边界/transcript 流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇中派生 UI。 -四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其会话;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他会话则渲染其持久 id。规范记录仍是事件溯源会话日志。 +四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类体系中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其会话;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他会话则渲染其持久 id。规范记录仍是事件溯源会话日志。 -步骤镜像(完全没有消费方)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 +步骤镜像(完全没有消费方)最先在[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此「ui-stdio 需要它」并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 ## 范围:移除什么、不移除什么 @@ -35,7 +35,7 @@ Status: implemented - `agent/steering`——不是边界,因此不在本决策范围内(原始提案将其一并移除;在此会造成范围蔓延)。它镜像持久的 `steering/message` 控制记录,而非边界,后来由自己的后续决策移除:[移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md)。 - `agent/stream-chunk`——实时 token 流。不在本决策范围内(它镜像持久的 `assistant/chunk`,而非边界),后来由自己的后续决策移除:[停止将 token 流镜像为 agent 事件](../../archived/simplification/2026-07-02-remove-stream-chunk-mirror.md)。 -- `agent/created`、`agent/disposed`、`agent/status`、`agent/error`、`agent/queued`——不属于 transcript 数据的生命周期/控制事件。尤其是 `agent/queued`,它是在任何持久事件存在之前触发的 inbox 确认(取消的排队工作可能永远不会进入日志),所以有意只保留为实时事件。 +- `agent/created`、`agent/disposed`、`agent/status`、`agent/error`、`agent/queued`——不属于 transcript 数据的生命周期/控制事件。尤其是 `agent/queued`,它是在任何持久事件存在之前触发的收件箱确认(取消的排队工作可能永远不会进入日志),所以有意只保留为实时事件。 ## 曾考虑的替代方案 @@ -44,4 +44,4 @@ Status: implemented ## 后果 -插件不能再从便捷的 `Agent` 优先事件观察轮次/步骤边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费方不应依赖可能与持久日志发生漂移的第二条事件 feed。 +插件不能再通过便捷的、以 `Agent` 为首个参数的事件观察轮次/步骤边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 查找共享 id 对应的对象,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费方不应依赖可能与持久日志发生漂移的第二个事件源。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index 291dd7d009..dff6c560ff 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md 2026-06-20-unify-agent-and-session-id.md: c55152f4f13fe0acb530503e84f465799007cff7 -2026-06-20-unify-agent-and-session-id.zh.md: 1fa2fe1fd64478bfe17c590e45abd0cf8281cbe4 +2026-06-20-unify-agent-and-session-id.zh.md: 92774a3a1b2ff90ccc6163967b4281552a253575 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md index 1fa2fe1fd6..92774a3a1b 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -16,7 +16,7 @@ ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和钩 ## 决策 -agent 的注册表 id 等于其会话 id。`CreateAgentOptions` 接受一个 `sessionId`,同时用于两个最终注册表条目;恢复时以 `resumeSessionId` 注册 agent;进程内 subagent 创建使用子会话 id;`Session.id` 则派生自 `header.id`。远程 ACP 运行没有本地 agent/会话对:它保留一个由父项铸造的生命周期 id,而子服务器线协议内的会话 id 仅用于 ACP 调用。现有创建事务、最终条目冲突检查和精确条目分离语义保持不变;唯一职责是在本地 id 之间转换的 map 与字段已经消失。 +agent 的注册表 id 等于其会话 id。`CreateAgentOptions` 接受一个 `sessionId`,同时用于两个最终注册表条目;恢复时以 `resumeSessionId` 注册 agent;进程内 subagent 创建使用子会话 id;`Session.id` 则派生自 `header.id`。远程 ACP 运行没有本地 agent/会话对:它保留一个由父项铸造的生命周期 id,而子服务器仅在协议交互中使用的会话 id 对 ACP 调用保持私有。现有创建事务、最终条目冲突检查和精确条目分离语义保持不变;唯一职责是在本地 id 之间转换的 map 与字段已经消失。 配置驱动路径保留 `agents[].id` 作为稳定配置标签,而非实时路由 identity。普通的全新启动会铸造组合 id `${label}-session-${randomUUID()}`,使持久重启不会冲突。耦合应用可以预先铸造并传入精确的 `sessionId`:首次使用时创建它,而当持久化服务已经存在时,AgentLoop 重新挂载会在同一 identity 下恢复已物化历史。`resumeSessionId` 则要求已有的持久化 identity。两个精确 id 输入互斥。Stdio 使用“恢复或创建”形式,使配置创建的 agent 和 UI 在循环重载之间共享一个不透明 identity,而不是根据前缀猜测。日志可以使用稳定标签,而所有实时与持久查找都使用同一个 `SessionId`。 @@ -28,12 +28,12 @@ agent 的注册表 id 等于其会话 id。`CreateAgentOptions` 接受一个 `se ## 验证 -- Agent 创建/恢复和 subagent 创建只携带一个 identity,`Session` 也只在一个位置存储它。 +- agent 创建/恢复和 subagent 创建只携带一个 identity,`Session` 也只在一个位置存储它。 - 创建事务继续覆盖最终条目冲突、精确条目分离、回滚和完全停稳,无需 identity 特有的生命周期状态。 - ACP、stdio、钩子、bash 归属、持久化和 lineage 直接使用共享 `SessionId`。ACP subagent 后端在父命名空间中铸造其生命周期 id,因为子服务器返回的会话 id 仅在服务器本地有效;ACP bridge 根据正向会话 map 验证精确的 `Agent` 归属;JSON-RPC 只转发生命周期事件中由服务快照保存的 `local` 标记为 true 的事件,从带范围的事件 carrier 取得委托父项,并且不保留子 identity 或 lineage cache。 -- 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景下得到覆盖。 +- 配置驱动的恢复或创建策略是显式的,并在持久化重启场景下得到覆盖。 - 生产监听器搜索确认保留 `agent/created`/`agent/disposed` 及其发布语义。 -- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 +- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index 23aedfe574..f0e7bb8fb9 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md 2026-06-26-fsspec-style-fs-seam.md: b5c201fb192782f130d3609978d16f0fc6d4d55e -2026-06-26-fsspec-style-fs-seam.zh.md: 3e4e6c439c85cc8e105766ee7f43c95640e26a43 +2026-06-26-fsspec-style-fs-seam.zh.md: 8346ba76d099a458236e200f304c4921553584d3 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index 3e4e6c439c..8346ba76d0 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -15,7 +15,7 @@ Status: implemented 这还造成了一个真实的用户体验死胡同:窗口化读取记录 `view: partial`,而 partial 视图无法授权 `edit`。一个模型读取了大文件的第 100-150 行,如果想编辑第 120 行,就必须先获取一次 `full` 读取,而对于超过读取上限的文件这可能做不到。字面编辑实际上只需要新鲜度:被匹配的字节仍然来自模型所读取的那个版本即可。 -旧 Agent Note(agent 决策记录)已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包。本 Agent Note 构建该层,使 `ctx.fs` 保持接近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不把它变成完整的 fsspec。 +旧 Agent Note 已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包。本 Agent Note 构建该层,使 `ctx.fs` 保持接近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不把它变成完整的 fsspec。 ## 决策 @@ -57,15 +57,15 @@ type FsWriteIntent = `stat` 返回元数据而非内容。`version` 是新鲜度令牌;`type` 让执行器在读取前拒绝目录/特殊文件;`size` 让 `read` 工具无需通过失败探测即可选择 `readText` 还是 `streamText`。`undefined` 表示目标不存在。 -`readText` 读取整个常规文本文件。`streamText` 以相同的文本语义流式读取大文件。两个提供方原语负责常规文件检查、UTF-8 解码、二进制/NUL 拒绝以及 `FS_NOT_TEXT`;策略层从不处理原始字节,也不重新实现跨分片解码。`readText` 是小文件/直接全文件原语,而面向模型的大文件读取使用 `streamText`。 +`readText` 读取整个普通文本文件。`streamText` 以相同的文本语义流式读取大文件。两个提供方原语负责普通文件检查、UTF-8 解码、二进制/NUL 拒绝以及 `FS_NOT_TEXT`;策略层从不处理原始字节,也不重新实现跨分片解码。`readText` 是小文件/直接全文件原语,而面向模型的大文件读取使用 `streamText`。 -`writeText` 是原子的临时文件 + rename,带有显式的写入期望。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 +`writeText` 通过临时文件 + rename 实现原子写入,并带有显式的写入期望。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 `editText` 是提供方级别的受保护文本变更。启用守卫时,它首先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。陈旧检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容进行匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定的能力,也让未来的远程后端能够实现原生的 compare-and-edit,而无需策略层拉取整个文件。 这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 -从 `dsh-fs` 删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody` 和 observed-state `WeakMap`。`applyEdit` 由更窄的提供方原语 `editText` 取代,其契约是带版本守卫的字面文本变更,而非策略层读取授权。`FS_PARTIAL_OBSERVATION` code 也从 `FsErrorCode` 分类中移除:新鲜度授权没有部分/完整之分,因此没有任何路径会抛出它。`FsTargetKey` 和 `FsVersion` 按现有[品牌化 id Agent Note](../architecture/2026-06-20-branded-ids.md) 成为品牌化不透明 id。 +从 `dsh-fs` 删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody` 和 observed-state `WeakMap`。`applyEdit` 由更窄的提供方原语 `editText` 取代,其契约是带版本守卫的字面文本变更,而非策略层读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有部分/完整之分,因此没有任何路径会抛出它。`FsTargetKey` 和 `FsVersion` 按现有[品牌化 id Agent Note](../architecture/2026-06-20-branded-ids.md) 成为品牌化不透明 id。 ## 策略契约 @@ -76,14 +76,14 @@ type FsWriteIntent = 该插件决定三个 `fs/*` 事件: - `fs/write-intent`——无先前观测 ⇒ `{ kind: 'createIfAbsent' }`(只有新文件可以盲创建);有先前观测 ⇒ `{ kind: 'replaceIfVersion', version: vObserved }`(已有文件仅在自观测以来未变时才替换)。单槽决策;不调用 `next()`。 -- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一赢一陈旧。 +- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一个成功,另一个因版本陈旧而失败。 - `fs/observed`——在成功的读取/写入/编辑后,为该 owner+target 记录 `{ version }`。同步、仅副作用的 `WeakMap.set`。 该插件不做任何文件系统 I/O:「你是否观测过此文件?」是一次 `WeakMap` 查找,而「你读取的版本是否仍然是当前版本?」在 `ctx.fs.editText`/`writeText` 内部、与执行变更相同的原子锁中决定——插件只提供 `vObserved` 作为基础。 ## 工具契约 -`dsh-tool-fs` 保持相同的 schema 和提示词表面。`read` 仍然暴露 `file_path`、`offset` 和 `limit`;`write` 和 `edit` 不变。它是执行器:验证模型参数,通过 `ctx.fs` 直接读取/写入/编辑,拥有行窗口化和结果渲染(`N: text`、页脚、`<path>/<content>` 信封),并分发 `fs/*` 事件。 +`dsh-tool-fs` 保持相同的 schema 和提示词表面。`read` 仍然暴露 `file_path`、`offset` 和 `limit`;`write` 和 `edit` 不变。它是执行器:验证模型参数,通过 `ctx.fs` 直接读取/写入/编辑,拥有行窗口化和结果渲染(`N: text`、页脚、`<path>/<content>` 封装),并分发 `fs/*` 事件。 每个变更操作先分发其 intent waterfall(瀑布式事件),带有 `undefined` 裸提供方默认值,然后调用 `ctx.fs`,再发出 `fs/observed`。例如 `write` 执行 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`。`read` 先 stat 一次,然后读取/流式读取,构建窗口,最后发出 `fs/observed`。将 `exec` 作为 actor 传递,让 `dsh-fs-policy` 无需工具深入策略即可派生 owner。 @@ -109,7 +109,7 @@ type FsWriteIntent = ## 验证 -`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不包含行、视图或 `formatReadBody` 逻辑;面向模型的 schema 保持逐字节不变。测试固定了以下行为:窗口化读取授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得以保留;观测契约成立(`read` 工具的读取记录观测状态;直接 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR(热模块替换)/dispose(资源释放)覆盖率。 +`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不包含行、视图或 `formatReadBody` 逻辑;面向模型的 schema 保持逐字节不变。测试固定了以下行为:窗口化读取授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得以保留;观测契约成立(`read` 工具的读取记录观测状态;直接 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR(热模块替换)/dispose(资源释放)测试覆盖。 ## 后续扩展 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml index 8947371a42..eb7f6a02b9 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md 2026-07-04-drop-image-content-block.md: cdedf4bd5dfe60c72cea185d88b83d3b93928ff0 -2026-07-04-drop-image-content-block.zh.md: 683fd1cdb47e3fcd68ff601c0b0ce4b46f8b06d2 +2026-07-04-drop-image-content-block.zh.md: 4caf717a360c1a3d536415a84e57fd9fff32c710 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md index 683fd1cdb4..4caf717a36 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -6,24 +6,24 @@ Status: implemented ## 问题 -`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:DeepSeek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。ACP(Agent Client Protocol)独立地拒绝图像提示词内容。此时构造的 `ImageBlock` 会在提供方协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:DeepSeek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;压缩(compaction)估算器为其按固定常量计入 token 用量,并将其渲染为 `[image]`。ACP(Agent Client Protocol)独立地拒绝图像提示词内容。此时构造的 `ImageBlock` 会在提供方协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 ## 决策 -移除 `ImageBlock`、其 map 条目,以及适配器和压缩中的 image 专用分支。在同一个变更中更新所属的词汇文档与生成的引用。未知扩展块仍然覆盖默认分支,ACP 继续独立于 harness 词汇拒绝入站的图像提示词内容。 +移除 `ImageBlock`、其 map 条目,以及适配器和压缩中的 image 专用分支。在同一个变更中更新所属的词汇文档与生成的参考文档。未知扩展块仍然覆盖默认分支,ACP 继续独立于 harness 词汇拒绝入站的图像提示词内容。 ## 曾考虑的替代方案 ### 为什么不保留? -当适配器和压缩支持 image 时,`ContentBlockMap` 可以重新引入。ACP 可以继续作为纯文本的自动化协议。保留一个唯一实现就是拒绝的核心类型,等于宣告一个不可用的对外服务接口;移除后,生产者会立即得到编译期错误。 +当适配器和压缩支持 image 时,`ContentBlockMap` 可以重新引入 image 内容块。ACP 可以继续作为纯文本的自动化协议。保留一个唯一实现就是拒绝的核心类型,等于宣告一个不可用的对外服务接口;移除后,生产者会立即得到编译期错误。 评审中记录的回退方案(假如评审决定保留该槽位):保留 `ImageBlock`,但将所有静默跳过替换为显式拒绝,并在词汇文档中记录该策略——静默丢弃是唯一没有辩护者的状态。评审最终决定移除;此回退方案作为文档化的替代方案保留,以备该槽位在完整功能就绪之前回归。 ## 验证 -除 Agent Note(agent 决策记录)之外,没有任何地方构造 harness `ImageBlock`。ACP 独立的入站图像拒绝路径仍有测试;适配器、codec 和压缩的默认分支则使用插件定义的块类型覆盖。 +除 Agent Note 之外,没有任何地方构造 harness `ImageBlock`。ACP 独立的入站图像拒绝路径仍有测试;适配器、codec 和压缩的默认分支则使用插件定义的块类型覆盖。 ## 后果 -日后重新添加核心词汇类型需要同时改动多个包(package)——但这种协调变更本就是真正的多模态功能所需的形态(适配器映射与压缩定价),而当前并不存在需要保留的实现。 +日后重新添加核心词汇类型需要同时改动多个包——但这种协调变更本就是真正的多模态功能所需的形态(适配器映射与压缩定价),而当前并不存在需要保留的实现。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index 002473026f..be9546102e 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md 2026-07-04-tighten-hook-protocol-contract.md: a67d0e8447e36516006e57051581c03877c1ba12 -2026-07-04-tighten-hook-protocol-contract.zh.md: c0f97cf39adb0fd17caa3ad2c518d26736bfc7a6 +2026-07-04-tighten-hook-protocol-contract.zh.md: 638455d89923d0636483c8e8f39fb0613daa1376 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index c0f97cf39a..638455d899 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 +# Agent Note: 收紧 hook-protocol 契约——dialect、被丢弃的字段、双重默认值与 lib 拥有的 `hook/result` 语义 Status: implemented @@ -6,16 +6,16 @@ Status: implemented ## 问题 -`dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note(agent 决策记录)](../../archived/feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费方而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: +`dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费方而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: 1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截 seam Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native 钩子不是一个包,并且“native 插件无需持久钩子日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 -2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有合并 fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:钩子 stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此钩子作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 -3. **`defaultTimeoutMs` 在两个 bridge 配置中以浮动字面量双重默认**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* +2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有合并 fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:钩子 stdout 从不进入任何 transcript(文本记录);上下文仅通过 `additionalContext` 流入,日志也只记录 `decision`/`stderrSummary`。因此,钩子作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 +3. **`defaultTimeoutMs` 在两个 bridge 配置中都以游离的字面量重复设置了默认值**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* 4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 与 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同;decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 同样如此。然而 `dsh-hook-protocol` 声明了 `hook/result`、在文档中将 `stderrSummary` 描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。 ## 决策 -`HookDialect` 是封闭的 bridge 集合:`'claude' | 'codex'`;`HookOutput` 移除了不受支持的 `suppressOutput`。`hook/result.durationMs` 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:`DEFAULT_HOOK_TIMEOUT_MS` 与 `DEFAULT_STDERR_SUMMARY_MAX_CHARS`。`HookResultRecord` 与 `appendHookResult` 为两个 bridge 统一拥有 stderr 摘要化和 decision 推导逻辑。`BLOCKING_EXIT_CODE` 为 codec 内部常量。 +`HookDialect` 是封闭的 bridge 集合:`'claude' | 'codex'`;`HookOutput` 移除了不受支持的 `suppressOutput`。`hook/result.durationMs` 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:`DEFAULT_HOOK_TIMEOUT_MS` 与 `DEFAULT_STDERR_SUMMARY_MAX_CHARS`。`HookResultRecord` 与 `appendHookResult` 共同负责两个 bridge 的 stderr 摘要化和 decision 推导逻辑。`BLOCKING_EXIT_CODE` 为 codec 内部常量。 ## 曾考虑的替代方案 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -`dialect`、`suppressOutput`、可调参数和语义变更在线协议和预期输出中均不可见。代价是 `dsh-hook-protocol` 和两个 bridge 中的改动——在预发布立场下成本很低,也比让一项持久事件语义的两个副本各自老化更便宜。 +`dialect`、`suppressOutput`、可调参数和语义变更在协议格式(wire format)和预期输出中均不可见。代价是 `dsh-hook-protocol` 和两个 bridge 中的改动——在预发布立场下成本很低,也比让一项持久事件语义的两个副本各自老化更便宜。 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index 64bea6aec8..1a4259e08c 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md 2026-07-12-simplify-session-log-representation.md: a40f4013a97a9c940012dbb37d59beb2faf8fb22 -2026-07-12-simplify-session-log-representation.zh.md: a4ecd8c7340affda71d95505ab44910539e36bb9 +2026-07-12-simplify-session-log-representation.zh.md: 30808a4eff83b32bd0c47cc6e6cb211f2222b363 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md index a4ecd8c734..30808a4eff 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -12,24 +12,24 @@ Status: implemented 请求头子系统实现了一套自定义的系统/工具增量编解码器和传输决策层,尽管其契约声明增量只是编码优化,而非可重建性要求。在每个 agent loop(智能体循环)实例边界保留初始/恢复的完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返回退逻辑以及持久化的 `request/header-delta` 变体。编解码器专属的词汇随编解码器一起消失,并非因为其各分支本身无效。 -实现保留追加与替换 `sourceEventSeqs`、崩溃修复 provenance,以及所有 `SessionStartSource` 变体,因为这些字段承担审计/拦截职责,当前没有读取方并不能推翻这一点。 +实现保留追加与替换操作的 `sourceEventSeqs`、崩溃修复的溯源信息,以及所有 `SessionStartSource` 变体,因为这些字段承担审计/拦截职责,当前没有读取方并不能推翻这一点。 ## 决策 -`SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩使用事件序号与 surface 位置;由 compact 拥有的每切点 balance cache 不依赖 node 链接。 +`SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩(compaction)使用事件序号与 surface 位置;由 compact 拥有的每个切点的 balance cache 不依赖 node 链接。 请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。 -`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一响亮失败边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为完整固定请求头和完整可读提示词。 +`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一失败即报错的边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为完整固定请求头和完整可读提示词。 ## 曾考虑的替代方案 -**保留链表节点和紧凑增量以备未来扩展。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时可以缩减日志。但没有已发布的游标使用这些链接,而完整快照以磁盘空间换取了显著更简单的正确性。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 +**保留链表节点和紧凑增量以备未来扩展。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时可以缩减日志。但没有已发布的游标使用这些链接,而完整快照以磁盘空间为代价,显著简化了正确性保障。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 ## 验证 -单元覆盖率固定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在重放前拒绝旧事件。无密钥 ACP 套件以新形状覆盖记录、刷新、重放、变化请求头固定,以及沙箱模式切换 fixture(测试前置数据)。 +单元测试覆盖并锁定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在回放前拒绝旧事件。无密钥 ACP 套件以新形状覆盖记录、刷新、回放、变更后请求头的固定,以及沙箱模式切换 fixture(测试前置数据)。 ## 后果 -完整请求头会增加日志体积,线性替换查找在极大 surface 上也可能较慢。由于先前实现调用 `indexOf`,替换原本就是线性的;benchmark 推迟到真实 trace 表明更简单的数组成为瓶颈时再进行。格式版本仍为 `0`,因此显式拒绝旧事件是预发布格式边界的永久组成部分。作为交换,surface 顺序和请求头状态现在各自只有一种表示,删除了链接维护、map、codec 分支、往返 fallback 和感知 delta 的快照规范化。 +完整请求头会增加日志体积,线性替换查找在极大 surface 上也可能较慢。由于先前实现调用 `indexOf`,替换原本就是线性的;benchmark 推迟到真实 trace 表明更简单的数组成为瓶颈时再进行。格式版本仍为 `0`,因此显式拒绝旧事件是预发布格式边界的永久组成部分。作为交换,surface 顺序和请求头状态现在各自只有一种表示,删除了链接维护、map、codec 分支、往返 fallback 和针对 delta 的快照规范化。 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 7d841e0bf9..3b9345a404 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md -2026-07-17-one-send-one-turn.md: 3ae43f137206f25bdbc563875c17e24211f17d6b -2026-07-17-one-send-one-turn.zh.md: 5ccdb2192048ecf795415bcd427f967df6a609fb +2026-07-17-one-send-one-turn.md: 834eb2b3aace0a0aa5f86f483e7b7d7aabbe467e +2026-07-17-one-send-one-turn.zh.md: d22893b912462cffffb0d6ecd81811e9cb5c523d diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 3ae43f1372..834eb2b3aa 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -10,21 +10,21 @@ Suppose a caller submits message A and then message B with two `Agent.send()` ca That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. -This grouping changes behavior, not just the number of model calls. One ordinary turn owns prompt admission, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Allowing one message while blocking another also requires a mixed state that no caller requested. +This grouping changes behavior, not just the number of model calls. One ordinary turn owns one claimed follow-up, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Entering one follow-up while rejecting another also requires a mixed state that no caller requested. ## Decision The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Before enqueueing an item, `send()` checks the agent state and accepts an already identified, deeply frozen message. It mints an occurrence-local `InboxItemId` and publishes `agent/inbox/enqueue`; the pending occurrence remains addressable under the [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision until the driver claims or discards it. +Before inserting a message, `send()` checks the agent state and accepts an already identified, deeply frozen value. The durable splice and `agent/inbox/inserted { message }` retain its `MessageId`; the pending message remains addressable through `Inbox.replace()` and `Inbox.remove()` until the driver claims or discards it. The [claimed pre-step inbox decision](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns the current lifecycle. If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. -Prompt admission decides one message at a time before a turn opens. An allowed prompt becomes that turn's `user/message`; a blocked prompt is discarded without opening a turn or writing session history. Mixed-batch and all-blocked-batch branches do not exist. +At a turn boundary, the loop opens the turn and claims one follow-up after pending next-step input. `agent/pre-step` either rejects the proposal or returns the complete entering batch. A rejected follow-up remains removed and closes a blocked no-step turn without writing model-visible history. Mixed ordinary follow-up branches do not exist. -The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; a request-error retry action or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. +The no-batching rule applies only to ordinary follow-up input. `steer()` puts input in the next-step inbox and wakes the driver. During a turn, the loop can claim it at a later step boundary; while idle, the waking next-step batch starts a new turn. Input arriving after a batch was claimed waits for a later boundary, while cancellation or disposal can discard it. -`inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends a `user/message` directly, without opening a turn or running the model. Persistence owns the resulting eager drain. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open. +`inject()` continues to add model-facing context without submitting ordinary input or waking the driver. It always waits in the next-step inbox for a later pre-step, including while idle; AgentLoop records it as `user/message` only when an enter decision returns it inside a turn. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary input, steering, and injection and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. ## Alternatives considered @@ -35,11 +35,11 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i - Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. - A built-stdio test submits two lines and observes two model requests and two turn boundaries. - Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. -- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; rejected admission creates no turn, recorded turns stay balanced, messages do not merge, and surviving queued work still drains. -- Separate tests cover open-turn, failed-turn, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. +- Failure-path tests cover pre-step rejection, listener failure, broad cancellation, disposal, and failure before `turn/start`; initial pre-step exits close balanced no-step turns, messages do not merge, and surviving later work still drains. +- Separate tests cover open-turn, failed-turn, and idle `steer()`, pending `inject()`, whole-agent status, and `whenIdle()`. ## Consequences -Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending occurrence can be removed through its live `InboxItemId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending message can be removed through its `MessageId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 5ccdb21920..d22893b912 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -10,21 +10,21 @@ Status: implemented 这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息,还需要引入调用方没有请求的混合状态。 +这种分组改变的不只是模型调用次数。一个普通轮次包含一条已领取 follow-up、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统让一条 follow-up 进入、却拒绝另一条,还需要引入调用方没有请求的混合状态。 ## 决策 规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -队列项入队之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的消息。它会铸造一个仅属于本次入队的 `InboxItemId`,并发布 `agent/inbox/enqueue`;根据[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策,在驱动器认领或丢弃该项之前,这次待处理入队始终可以被寻址。 +消息插入之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的值。持久 splice 与 `agent/inbox/inserted { message }` 会保留其 `MessageId`;在驱动器领取或丢弃该消息之前,可以通过 `Inbox.replace()` 与 `Inbox.remove()` 寻址。当前生命周期由[已领取 pre-step inbox 决策](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md)规定。 -如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 +如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。面向整个 agent 的 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 -提示词准入会在轮次打开前,每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词会被丢弃,不打开轮次,也不写入会话历史。实现中不存在混合批次或全阻止批次分支。 +轮次边界上,循环会先打开轮次,再在待处理 next-step 输入之后领取一条 follow-up。`agent/pre-step` 要么拒绝提案,要么返回进入步骤的完整批次。被拒绝的 follow-up 保持已删除,并关闭一个 blocked 的无步骤轮次,不写入模型可见历史。实现中不存在混合普通 follow-up 分支。 -上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 +上述不合批规则只适用于普通 follow-up 输入。`steer()` 会把输入放入 next-step inbox 并唤醒驱动器。在轮次期间,循环可以在后续步骤边界领取它;agent 空闲时,这个会唤醒的 next-step 批次会启动一个新轮次。批次被领取后才到达的输入会等待后续边界,而取消或 dispose 可以将其丢弃。 -`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。 +`inject()` 继续添加面向模型的上下文,但不提交普通输入,也不唤醒驱动器。即使 agent 空闲,它也始终在 next-step inbox 中等待后续 pre-step;AgentLoop 只会在 enter 决策于轮次内返回它时,将其记录为 `user/message`。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入、steering 和注入,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。 ## 曾考虑的替代方案 @@ -32,14 +32,14 @@ Status: implemented ## 验证 -- 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 +- 单元测试和基于属性的测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 - stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 - 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 -- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;准入拒绝不会创建轮次,已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 -- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 +- 失败路径测试覆盖 pre-step reject、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;首次 pre-step 的各种退出都会关闭边界平衡的无步骤轮次,消息不会合并,之后仍需处理的工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及待处理的 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 -普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理项可通过其仍有效的 `InboxItemId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理消息可通过其 `MessageId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml index edb7429454..d6fa35a75d 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-unwrap-injected-content-envelopes.md: 32642660f7bcea748c349933b99552b1974922c5 -2026-07-20-unwrap-injected-content-envelopes.zh.md: a01a51e12cecca5bc46526ccca61dbe90eb3136f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md +2026-07-20-unwrap-injected-content-envelopes.md: aff458d76027bf5518497328ee1bd852869793b9 +2026-07-20-unwrap-injected-content-envelopes.zh.md: 59cbd823da3305185a05c79207fbd7b702a11fba diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md index 32642660f7..aff458d760 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md @@ -15,7 +15,7 @@ Two problems: ## Decision -Injected session content projects verbatim; the caller owns any framing. `deriveEventMessage` renders `user/message`, `context/message`, and `steering/message` through one shared case returning `{ role: 'user', content: event.data.content }`; their content blocks reach the model unchanged. `context/message`'s `source`/`meta` and `steering/message`'s `turn` stay in the durable event log but do not render. +Injected session content projects verbatim; the caller owns any framing. `deriveEventMessage` renders `user/message` content blocks to the model unchanged; `source` stays in the durable event log but does not render. The `ContextEnvelope` type and every `envelope` field are removed — `context/message` in `SessionEventMap`, `InjectOptions`, `HookContext`, and the `inject()`/`additionalContexts` plumbing in `dsh-agent-loop`. `workspace-context` no longer requests `'raw'`; its self-framed content renders as before. The `renderTagged`/`renderContextEnvelope` helpers are deleted. `context/message.meta` still carries durable, model-hidden JSON state. diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md index a01a51e12c..59cbd823da 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md @@ -10,32 +10,32 @@ Status: implemented 两个问题: -- **没有模型在这些标签上训练过。** `<steering>` 和 `<context>` 是任何模型都未被教会去读的任意标记,因此这层框架只是徒增 token 而没有可靠效果,还可能起反作用——已录制的 transcript 显示,模型会把 `<steering>` 指令当成第三方元数据而拒绝服从,只回答原始提示。 -- **session 表层是承载框架的错误层次。** 表层的职责是把持久日志投影为模型 transcript;决定内容如何措辞并不是它的事。想要特定框架的调用方可以在注入前自行格式化内容——唯一的重度生产方(`workspace-context`)本就这样做,它自带完整的 `<system-reminder>` 框架,并用 `envelope: 'raw'` 退出 `<context>` 封套。剩下的标签机制(`ContextEnvelope` 类型,以及贯穿 `InjectOptions`、`HookContext`、`context/message` 事件和 agent loop 的 `envelope` 字段)所服务的区分,本应归属调用方。 +- **没有模型在这些标签上训练过。** `<steering>` 和 `<context>` 是任何模型都未被教会去读的任意标记,因此这层框架只是徒增 token 而没有可靠效果,还可能起反作用——已录制的 transcript 显示,模型会把 `<steering>` 指令当成第三方元数据而拒绝服从,只回答原始提示词。 +- **会话表层是承载框架的错误层次。** 表层的职责是把持久日志投影为模型 transcript;决定内容如何措辞并不是它的事。想要特定框架的调用方可以在注入前自行格式化内容——唯一的重度生产方(`workspace-context`)本就这样做,它自带完整的 `<system-reminder>` 框架,并用 `envelope: 'raw'` 退出 `<context>` 封套。剩下的标签机制(`ContextEnvelope` 类型,以及贯穿 `InjectOptions`、`HookContext`、`context/message` 事件和 agent loop(智能体循环)的 `envelope` 字段)所服务的区分,本应归属调用方。 ## 决策 -注入的会话内容逐字投影,框架由调用方自行负责。`deriveEventMessage` 通过一个共享分支渲染 `user/message`、`context/message` 和 `steering/message`,都返回 `{ role: 'user', content: event.data.content }`;它们的内容块原样到达模型。`context/message` 的 `source`/`meta` 和 `steering/message` 的 `turn` 保留在持久事件日志中,但不渲染。 +注入的会话内容逐字投影,框架由调用方自行负责。`deriveEventMessage` 把 `user/message` 的内容块原样送达模型;`source` 保留在持久事件日志中,但不渲染。 `ContextEnvelope` 类型和所有 `envelope` 字段都被移除——包括 `SessionEventMap` 中的 `context/message`、`InjectOptions`、`HookContext`,以及 `dsh-agent-loop` 中 `inject()`/`additionalContexts` 的相关管线。`workspace-context` 不再请求 `'raw'`;它自带框架的内容渲染方式不变。`renderTagged`/`renderContextEnvelope` 辅助函数被删除。`context/message.meta` 仍携带持久的、对模型隐藏的 JSON 状态。 -封套曾携带的 `source` 归属并未丢失——它仍保留在持久事件上;只是不再渲染进 transcript。 +封套曾携带的 `source` 来源信息并未丢失——它仍保留在持久事件上;只是不再渲染进 transcript。 ## 权衡的替代方案 -- **保留 `<context>` 封套,只对 steering 去封套** —— 会为一个没有模型会读的框架位保留 `ContextEnvelope`/`envelope` 机制,并保留主要生产方本就退出的那种不一致。 -- **仅对插件来源的内容保留 envelope 字段** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent(智能体)时(钩子桥接器的轮次续行原因)同样希望指令被遵从,而不是被贴标签。 +- **保留 `<context>` 封套,只对 steering(中途引导)去封套** —— 会为一个没有模型会读的框架位保留 `ContextEnvelope`/`envelope` 机制,并保留主要生产方本就退出的那种不一致。 +- **仅对插件来源的内容保留 envelope 字段** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent 时(钩子桥接器的轮次续行原因)同样希望指令被遵从,而不是被贴标签。 - **把去封套的逻辑移入适配器** —— 规范投影就是模型可见契约(「模型可见 ⟺ 已记录」);让各适配器在框架上各行其是,会使派生的 transcript 依赖于适配器。调用方确实想要的框架应放进调用方自己的内容里,而不是适配器。 ## 结果 -- 中途引导与注入的 context 以与普通用户提示相同的权重到达模型。 +- 中途引导与注入的上下文以与普通用户提示词相同的权重到达模型。 - transcript 不再区分注入内容与用户消息;需要这一区分的消费方读取持久事件日志,其中事件类型、`source` 和 `meta` 完整保留。 -- `hook-{cc,codex}-stop-continue` ACP 快照已重新录制:旧录制捕获的是模型把 steering 当作第三方元数据而拒绝服从,正是本次修复针对的失败模式。 +- `hook-{cc,codex}-stop-continue` ACP(Agent Client Protocol)快照已重新录制:旧录制捕获的是模型把 steering 当作第三方元数据而拒绝服从,正是本次修复针对的失败模式。 - [内容块词汇表 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md) 中关于带标签封套的条款已修订为指向本文。 ## 推迟事项 `workspace-context` 已经自行为内容加框架:它把一个完整的 `<system-reminder>…</system-reminder>` 块作为消息内容发出,而不依赖表层封套。这种调用方自有的模式才是应保留的——表层逐字透传内容,任何框架都住在生产方自己的内容里。 -曾经存在两条框架路径——调用方自行加框架(`workspace-context` 的 `<system-reminder>`),以及表层封套(`deriveEventMessage` 加上的 `<context>`/`<steering>`)。本次变更移除了后者,只留下调用方自有的框架。如果未来又需要带标签的框架,应由事件的 `meta` map(生产方附加、对模型隐藏的元数据字段)来统一它,交给专门的渲染器或适配器消费,而不是在 `deriveEventMessage` 中重新硬编码标签。生产方在 `meta` 中声明所需的框架,由一个渲染器统一施加;session 表层的投影始终保持逐字透传。 +曾经存在两条框架路径——调用方自行加框架(`workspace-context` 的 `<system-reminder>`),以及表层封套(`deriveEventMessage` 加上的 `<context>`/`<steering>`)。本次变更移除了后者,只留下调用方自有的框架。如果未来又需要带标签的框架,应由事件的 `meta` map(生产方附加、对模型隐藏的元数据字段)来统一它,交给专门的渲染器或适配器消费,而不是在 `deriveEventMessage` 中重新硬编码标签。生产方在 `meta` 中声明所需的框架,由一个渲染器统一施加;会话表层的投影始终保持逐字透传。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index dbd47ad90e..fbca845408 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-plan-specific-collaboration-state.md: fb26d15238f0eb1b63fdccc7e48a6c49a44236cf -2026-07-22-plan-specific-collaboration-state.zh.md: 93186eebb263458bc99e7f7562d065fbf9e5d4bf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +2026-07-22-plan-specific-collaboration-state.md: d139f3d861244c80acfb604d17172461bbf0cd57 +2026-07-22-plan-specific-collaboration-state.zh.md: a11ed1f270f58fc49a41be0e475889f84fa7cd40 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index fb26d15238..d139f3d861 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -14,7 +14,7 @@ Plan mode also needs a durable stance, a reviewable plan artifact, an explicit h ## Decision -Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning. +Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The pre-step, retry, append-failure, and disposal fences preserve the same state-transition ownership. Configuration is exactly `{ section: string }`. The package registers the fixed `plan:policy` section, `/plan [message]`, the exact `/plan off` direct-exit form, and `exit_plan_mode` itself. Bare `/plan` selects active; another non-empty argument selects it first and then sends the trimmed text through `agent.steer()`, making the text an ordinary logged user message in the affected step. `/plan off` selects inactive without model input and can cancel an entry that is still pending at the boundary. The exit tool remains registered while plan mode is inactive so the request tool catalog stays stable. @@ -24,7 +24,7 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei ### Boundary and model contract -`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly on prompt submission, ordinary continuation, or a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. +`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly at initial or continuation pre-step, or on a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 93186eebb2..a11ed1f270 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -产品只交付了 `plan`,首个 plan mode 实现却引入了通用的具名模式注册表。`ModeConfig.modes`、定义名称校验、`ctx.modes.list()`、已退役定义的回退逻辑,以及测试中合成的 `review` 模式,都只为支持假想中的未来协作模式而存在。plan 引导、`/plan` 和 `exit_plan_mode` 这些生产专用行为仍位于同一个包(package)内,因此通用 API 并未将可复用机制与 plan 策略隔离开来。 +产品只交付了 `plan`,首个 plan mode 实现却引入了通用的具名模式注册表。`ModeConfig.modes`、定义名称校验、`ctx.modes.list()`、已退役定义的回退逻辑,以及测试中合成的 `review` 模式,都只为支持假想中的未来协作模式而存在。plan 引导、`/plan` 和 `exit_plan_mode` 这些生产专用行为仍位于同一个包内,因此通用 API 并未将可复用机制与 plan 策略隔离开来。 「mode」一词还横跨互不相关的领域。沙箱模式是由 `ctx.sandboxPolicy` 拥有、以 `sandbox/mode` 记录日志的强制执行策略;plan mode 则是一种协作方式,会贡献引导内容和经评审的退出路径。若把两者都视为同一个具名模式抽象的实例,就会掩盖二者各自独立的归属关系。传输协议的通用词汇并不能证明 harness 需要通用模式领域。 -Plan mode 还需要持久协作状态、可评审的计划产物、显式人工决策边界,以及跨恢复与 fork 的请求重建。即使移除通用注册表和 ACP 交互投影,这些要求仍归 plan 功能所有。 +Plan mode 还需要持久协作状态、可评审的计划产物、显式人工决策边界,以及跨恢复与 fork 的请求重建。即使移除通用注册表和 ACP(Agent Client Protocol)交互投影,这些要求仍归 plan 功能所有。 ## 决策 -Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。 +Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。pre-step、重试、追加失败和 dispose(资源释放)栅栏保留相同的状态转换归属。 配置严格为 `{ section: string }`。该包自行注册固定的 `plan:policy` 段、`/plan [message]`、精确匹配的 `/plan off` 主动退出形式,以及 `exit_plan_mode`。不带参数的 `/plan` 选择激活;其他非空参数则先选择激活,再通过 `agent.steer()` 发送去除首尾空白后的文本,使该文本在受影响的步骤中成为一条记录到日志的普通用户消息。`/plan off` 选择未激活,不产生模型输入,并可取消仍待在边界生效的进入选择。即使 plan mode 未激活,退出工具仍保持注册,以确保请求工具目录稳定。 -面向人类的组合拥有 plan 选择与评审。本笔记最初把 ACP 协议级的 `default`/`plan` 选择器保留为布尔服务之上的适配器;[ACP 作为仅面向自动化的协议](2026-07-23-acp-automation-only-protocol.md)取代了那个线上投影,因此 ACP 组合现在既不挂载 plan mode,也不提供模式选择协议。 +面向人类的组合拥有 plan 选择与评审。本笔记最初把 ACP 协议级的 `default`/`plan` 选择器保留为布尔服务之上的适配器;[ACP 作为仅面向自动化的协议](2026-07-23-acp-automation-only-protocol.md) 取代了那个协议投影,因此 ACP 组合现在既不挂载 plan mode,也不提供模式选择协议。 沙箱模式与审批策略仍是彼此独立的强制约束轴。Plan mode 既不读取也不写入二者;此次简化也没有为这些概念引入共享基类型、注册表或预设抽象。 ### 边界与模型契约 -`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在提示词提交、普通 continuation 或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 +`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在初始或续步 pre-step、或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 @@ -32,7 +32,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` `exit_plan_mode` 要求调用方 agent 处于激活的 plan mode,并提交一份非空、以标题开头的 markdown 计划。用户交互问题将这份原样计划作为详情,并提供 `Approve`、`Keep planning` 和自由文本反馈。仅当唯一选择为 `Approve` 且没有自定义文本时才视为同意;其他所有回答都会留在 plan mode,并向模型返回纠正性反馈。经批准的退出会成为一项静默的待生效选择,使 plan 引导在当前工具批次的剩余部分继续有效,并在下一次请求前移除。 -工具将提交的计划渲染为 generic 卡片,标题取自第一个标题。用户交互提供方缺失或失败、评审失败,或评审待定期间插件被 dispose,都会失败关闭,并保留手动 `/plan off` 作为人类退出路径。 +工具将提交的计划渲染为 generic 卡片,标题取自第一个标题。用户交互提供方缺失或失败、评审失败,或评审待定期间插件被 dispose,均会拒绝退出,并保留手动 `/plan off` 作为人类退出路径。 ## 删除的接口 @@ -55,7 +55,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` **按 plan 专用名称允许列表或全局策略栈筛选工具。** 不予采纳,因为可变性是每个工具自身的属性,包括未来工具和 MCP 工具,而不应由每个 plan 部署维护一份列表。只有出现具体消费方后,effects 元数据才能建立共享策略;在此之前,plan mode 是引导机制,不是安全边界。 -**通过审批 seam 或普通文本完成评审。** 不予采纳,因为计划评审不是权限决策,需要精确的计划产物和纠正性自由文本,而且必须以已记录的工具调用作为结构化转换。用户交互 seam 提供了这项契约。 +**通过审批 seam 或普通文本完成评审。** 不予采纳,因为计划评审不是权限决策,需要原样的计划产物和纠正性自由文本,而且必须以已记录的工具调用作为结构化转换。用户交互 seam 提供了这项契约。 ## 验证 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index fe203d8f0f..3889001fe3 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md 2026-07-23-acp-automation-only-protocol.md: 0fe2fc27a963d21e8a24c1682359ab3bc9e7af48 -2026-07-23-acp-automation-only-protocol.zh.md: 0a471f0bf1b12e835660cdce2d2a2acd761e1b89 +2026-07-23-acp-automation-only-protocol.zh.md: bd21696c40811adeb8b8883ef57c30f9321f4ea4 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 0a471f0bf1..bd21696c40 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -1,4 +1,4 @@ -# Agent Note:ACP 作为仅面向自动化的协议 +# Agent Note: ACP 作为仅面向自动化的协议 Status: implemented @@ -6,11 +6,11 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。 +ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理(reasoning)、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。 ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控制器可以启动 harness 进程、创建隔离会话、发送文本、接收已提交的回答、取消工作并回答权限请求。跨进程 ACP subagent 后端依赖这个标准协议边界。 -快照套件使移除工作更复杂。大多数 ACP 场景测试的是组装后的 agent 后端,而不是 ACP 展示层;如果随编辑器桥接层一起删除整个套件,就会丢失大量无密钥行为覆盖。 +快照套件使移除工作更复杂。大多数 ACP 场景测试的是组装后的 agent 后端,而不是 ACP 展示层;如果随编辑器桥接层一起删除整个套件,就会丢失大量无密钥行为测试。 ## 决策 @@ -18,19 +18,19 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 -保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中的精确 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为失败关闭的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 +保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中登记的同一 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为拒绝请求的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。SDK 脚手架同样将 `ask_user_question` 视为 TUI 专属功能。 传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 -断开连接与插件 dispose(资源释放)共享同一个经记忆化处理的静止边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 +断开连接与插件 dispose(资源释放)共享同一个经记忆化的完全停稳边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 ## 快照边界 ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 -协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、精确 agent 权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍属于覆盖豁免,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 +协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 ## 考虑过的替代方案 @@ -42,7 +42,7 @@ ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后 **随其他交互功能一起移除机器权限请求。** 不予采用,因为自动化父 agent 必须回答子 agent 的一次性策略决策;这是 agent 之间的控制流,而不是展示层。 -**删除 ACP 快照套件,或在本次变更中迁移每个场景。** 不予采用,因为大多数场景测试后端且仍有价值,而完整的 harness 迁移是一项独立的测试变更。只有驱动脚本依赖已删除 UI 方法的场景才离开该套件。 +**删除 ACP 快照套件,或在本次变更中迁移每个场景。** 不予采用,因为大多数场景测试后端且仍有价值,而完整的 harness 迁移是一项独立的测试变更。只有通过已删除的 UI 方法驱动的场景才离开该套件。 ## 结果 @@ -50,4 +50,4 @@ ACP 具有适合 agent 与自动化的精简契约,而 TUI 和 Web 拥有面 自动化客户端收到完整的已提交文本,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。 -因此,后端快照覆盖仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。 +因此,后端快照测试仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index e3e24181d8..a570637be3 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md 2026-07-23-collapse-persistence-flush-state.md: a9b0f6847712f47d46adb6b01c57563033738964 -2026-07-23-collapse-persistence-flush-state.zh.md: acb9f798d86b4ec41d975d9de23f36080d3d7848 +2026-07-23-collapse-persistence-flush-state.zh.md: e665d164fb66b2bce97a4f6ded88f2ee07324e61 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index acb9f798d8..e665d164fb 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -16,7 +16,7 @@ Status: implemented 初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 -崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威事件生成快照,再将这些事件与 `SessionState.meta`(即持久化写入实际使用的标头)一同返回;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,`session/created` 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR 接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 +崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威事件生成快照,再将这些事件与 `SessionState.meta`(即持久化写入实际使用的标头)一同返回;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,`session/created` 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR(热模块替换)接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷新所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。 @@ -38,7 +38,7 @@ Status: implemented - 共享后端契约会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 - AgentLoop 回归测试让 `resume()` 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 `interrupted` 边界。 - 一个受控后端会阻塞 `loadStored`,在修复操作持有标识占用期间尝试发布同 id 会话,并证明回滚不会留下残留控制器,之后可以成功恢复一个闭合会话。 -- 无所有者声明契约会为活跃 `Session` 设置不同的 `createdAt`,并证明活跃加载和之后的冷态加载均返回最初存储的标头。 +- 无所有者认领契约会为活跃 `Session` 设置不同的 `createdAt`,并证明活跃加载和之后的冷态加载均返回最初存储的标头。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml index b524e96a02..507c607217 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md -2026-07-24-agent-loop-observable-state-machine.md: a25657c6a41e2c0989db620046f44ea3254be151 -2026-07-24-agent-loop-observable-state-machine.zh.md: 058d89d3cb9e3d30963f95fda1510ef3c5bf281e +2026-07-24-agent-loop-observable-state-machine.md: b1773fdf7fd3bcbea9c6b9a4a0ce37c715bce4ca +2026-07-24-agent-loop-observable-state-machine.zh.md: a4ed98117b965b445ec0888c55a1b1b27887532a diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md index a25657c6a4..b1773fdf7f 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md @@ -18,16 +18,16 @@ The public contract exposes four orthogonal state dimensions: - Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`. - Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval. -- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`. Enqueue and dequeue correlate an occurrence by `MessageId` plus its queued-or-steering placement; same-placement repeats retire in FIFO order. The inbox events describe acceptance, claim, and removal rather than turn completion. -- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. +- A pending message emits `agent/inbox/inserted` when inserted, then either `agent/inbox/claimed` after an atomic pure-deletion claim or `agent/inbox/discarded` after an ordinary removal. `MessageId` correlates the exact message; durable splice coordinates retain placement and cancellation. Inbox events describe insertion, claim, and discard rather than turn completion. +- A claimed turn passes through pre-step entry and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. -The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. +The loop keeps four machine extension events. `agent/pre-step` decides reject or enter for one exclusive claimed batch and runs before every proposed step. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying `concludesTurn` ends the tool loop at its step. The loop does not expose general `ContinuationDecision` or terminal-stop return channels. A model-request failure closes its step, then enters `agent/request-error` with the exact error, normalized `LlmFailure`, and live turn signal. A listener that owns recovery repairs state, returns `{ kind: 'retry' }`, and stops delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. `agent/settled` reports the terminal outcome, and `agent/error` remains the live error notification for consumers that report failures independently of turn settlement. The [retry-action decision](2026-07-27-request-error-retry-action.md) supersedes the command-shaped part of this design. -The event taxonomy removes `agent/pre-step`, `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. +The event taxonomy removes the legacy prompt preparation/submission and serial step hooks together with `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. The single `agent/pre-step` waterfall owns claimed-message entry. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. ## Alternatives considered @@ -51,7 +51,7 @@ The inbox lifecycle complements, rather than replaces, the durable session log. ## Related -- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [Unify agent delivery routing and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) - [Remove implicit batching from ordinary sends](2026-07-17-one-send-one-turn.md) - [Microkernel event taxonomy](../architecture/2026-06-11-microkernel-event-taxonomy.md) - [Bounded LLM request recovery](../architecture/2026-06-21-bounded-llm-request-recovery.md) diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md index 058d89d3cb..a4ed98117b 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 围绕可观察状态机收拢 agent loop(智能体循环)事件 +# Agent Note: 围绕可观察状态机收拢 agent loop 事件 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent loop 曾将其控制流暴露为大量 Cordis 事件。`pre-step` 和 `post-step` 两个独立检查点分列步骤前后,`session-prefix` 和 `step-result` 分别变换请求消息与响应消息,`request-error` 决定失败的请求是否在当前轮次内重试,`turn-continuation` 与 `turn-stop` 则组合相互竞争的继续执行决策。 +agent loop(智能体循环)曾将其控制流暴露为大量 Cordis 事件。`pre-step` 和 `post-step` 两个独立检查点分列步骤前后,`session-prefix` 和 `step-result` 分别变换请求消息与响应消息,`request-error` 决定失败的请求是否在当前轮次内重试,`turn-continuation` 与 `turn-stop` 则组合相互竞争的继续执行决策。 即使持久会话日志已经记录了对应的轮次与步骤事实,这些事件仍会将内部阶段公开。它们还混用了两种扩展模型:部分监听器观察边界并发出 agent 命令,另一些监听器则返回由循环解释的控制决策。因此,要理解公开状态机,必须同时还原事件顺序、waterfall(瀑布式事件)优先级和特殊的终止覆盖规则。 @@ -18,22 +18,22 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及 - 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。 - agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。 -- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一。enqueue 与 dequeue 通过 `MessageId` 加 queued 或 steering(中途引导)放置方式关联一次消息出现;放置方式相同的重复项按 FIFO 顺序结算。收件箱事件描述接受、领取和移除,而不是轮次完成。 -- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 +- 待处理消息插入时会发出 `agent/inbox/inserted`,随后要么在原子纯删除领取后发出 `agent/inbox/claimed`,要么在普通删除后发出 `agent/inbox/discarded`。`MessageId` 关联确切消息;持久 splice 坐标保留 placement 与取消信息。inbox 事件描述插入、领取和丢弃,而不是轮次完成。 +- 已领取的轮次经过 pre-step 进入决策和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 -循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering,循环在所有监听器完成后根据这份数据作出决定。 +循环保留四个状态机扩展事件。`agent/pre-step` 对独占的已领取批次执行 reject 或 enter 决策,并在每个拟议步骤前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering,循环在所有监听器完成后根据这份数据作出决定。 -是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。 +是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止返回通道。 -模型请求失败会先关闭当前步骤,再携带准确错误、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、返回 `{ kind: 'retry' }`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/settled` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。[重试动作决策](2026-07-27-request-error-retry-action.md)取代了本设计中命令形式的部分。 +模型请求失败会先关闭当前步骤,再携带该错误本身、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、返回 `{ kind: 'retry' }`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/settled` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。[重试动作决策](2026-07-27-request-error-retry-action.md)取代了本设计中命令形式的部分。 -事件分类体系移除了 `agent/pre-step`、`agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 +事件分类体系移除了旧的提示词准备/提交与串行 step hook,以及 `agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。唯一的 `agent/pre-step` waterfall 负责已领取消息能否进入步骤。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 ## 考虑过的替代方案 -**保留细粒度事件序列。** 这样可以为每个内部阶段保留专用拦截点,包括仅用于请求的前缀、助手消息改写、步骤后处理、轮次内请求恢复以及终止停止覆盖。但这也会使循环的私有执行顺序成为永久的公开契约,并允许相互重叠的 seam 表达彼此冲突的决策。当前决策接受这些拦截点的缺失,以换取每项受支持的扩展职责仅对应一个边界。 +**保留细粒度事件序列。** 这样可以为每个内部阶段保留专用拦截点,包括仅用于请求的前缀、助手消息改写、步骤后处理、轮次内请求恢复以及终止覆盖。但这也会使循环的私有执行顺序成为永久的公开契约,并允许相互重叠的 seam 表达彼此冲突的决策。当前决策接受这些拦截点的缺失,以换取每项受支持的扩展职责仅对应一个边界。 -**将 dispose 表示为第三种 `AgentStatus`。** 这样会让仍被持有的句柄得到一个终止状态值,但也会重复表达 `agent/disposed` 已经体现的注册表生命周期。当前决策让 `AgentStatus` 只表示活动中 agent 的状态,并将注册生命周期作为独立维度。 +**将 dispose 表示为第三种 `AgentStatus`。** 这样会让仍被持有的句柄得到一个终止状态值,但也会重复表达 `agent/disposed` 已经体现的注册表生命周期。当前决策让 `AgentStatus` 只表示 agent 存续期间的活动状态,并将注册生命周期作为独立维度。 **让 `agent/request-error` 返回重试决策。** 这一替代方案已由[重试动作决策](2026-07-27-request-error-retry-action.md)取代;新决策移除了重复命令,并将决策局限于 waterfall 的返回结果。 @@ -43,7 +43,7 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及 可观察状态机更小,也更容易组合:注册生命周期、活动状态、条目进度和终态结算可以分别追踪。尤其是,`agent/settled` 并不意味着 `agent.status === 'idle'`;前者报告一次排空链的终态轮次,`agent/status` 则报告整个 agent 是否处于活动状态。 -插件不再能够改写循环的每个阶段。不再提供仅用于请求的消息前缀、助手消息变换、步骤后检查点、通用的继续执行枚举、通用的终止停止结果或轮次内请求重试。扩展改用剩余的归属明确的通道,而不是重新构造这些阶段。 +插件不再能够改写循环的每个阶段。不再提供仅用于请求的消息前缀、助手消息变换、步骤后检查点、通用的继续执行枚举、通用的终止结果或轮次内请求重试。扩展改用剩余的归属明确的通道,而不是重新构造这些阶段。 负责继续执行的插件发布可持久化的 steering,而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误,并返回显式重试动作。这样,每次尝试都会成为完整轮次,同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。 @@ -51,8 +51,8 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及 ## 相关内容 -- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [统一 agent 交付路由,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) - [移除普通发送中的隐式批处理](2026-07-17-one-send-one-turn.md) - [微内核事件分类体系](../architecture/2026-06-11-microkernel-event-taxonomy.md) -- [有界 LLM 请求恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md) +- [有界 LLM(大语言模型)请求恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md) - [可重建的请求](../architecture/2026-07-05-reconstructable-requests.md) diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml index 860af008f4..4c0e0c5b0d 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md 2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 933b993d479026d8f2bd2dc3173abd9e60823806 +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 7c746079aa7012115bea05ec0191d665c8f860d2 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md index 933b993d47..7c746079aa 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`sse.ts` 将 SSE 分帧委托给 `eventsource-parser/stream` 的 `EventSourceParserStream`:`parseSse` 把响应 body 依次管道接入 `new TextDecoderStream()` 和 `new EventSourceParserStream()`,只保留 DeepSeek 协议垫层——逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。规范符合性测试已删除;`tests/sse.spec.ts` 只固定 `[DONE]`/`STREAM_CLOSED`/EOF 契约。`eventsource-parser` 是 `llm-deepseek` 继 schemastery 之后的第二个运行时依赖。曾把该适配器标为「手写 fetch + SSE 解析」的[孪生适配器 Agent Note(agent 决策记录)](../architecture/2026-06-13-twin-llm-adapters.md)与 `dsh-llm` JSDoc,现在将其描述为直接 fetch 加库分帧的 SSE。 +`sse.ts` 将 SSE 分帧委托给 `eventsource-parser/stream` 的 `EventSourceParserStream`:`parseSse` 把响应 body 依次管道接入 `new TextDecoderStream()` 和 `new EventSourceParserStream()`,只保留 DeepSeek 协议垫层——逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。规范符合性测试已删除;`tests/sse.spec.ts` 只固定 `[DONE]`/`STREAM_CLOSED`/EOF 契约。`eventsource-parser` 是 `llm-deepseek` 继 schemastery 之后的第二个运行时依赖。曾把该适配器标为「手写 fetch + SSE 解析」的[孪生适配器 Agent Note](../architecture/2026-06-13-twin-llm-adapters.md)与 `dsh-llm` JSDoc,现在将其描述为直接 fetch 加库分帧的 SSE。 该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供手写解析器缺少的 `maxBufferSize` 加固能力。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml index 41f59f644f..17590b4a15 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.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/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md 2026-07-26-turndown-for-tool-web-html-markdown.md: 0e387021e3d3be3011cc0d64d37864b30aec4fdf -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 44e7d08c1db40a1203e8cda955b521774335e774 +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 6c9b9a22dbb556cdf4eef210705e2a7e265447c4 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md index 44e7d08c1d..6c9b9a22db 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-tool-web` 的 `src/html.ts`(约 86 行,另有约 40 行专属测试;已由本变更删除)曾用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 `<a>`/`<h1-6>`/`<li>`,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;此前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 +`dsh-tool-web` 的 `src/html.ts`(约 86 行,另有约 40 行专属测试;已由本变更删除)曾用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 `<a>`/`<h1-6>`/`<li>`,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包,因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;此前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented ## 快照覆盖 -此前缺失的无密钥 `web_fetch` 快照随本变更以 acp-agent 场景 `web-fetch` 落地:`examples/acp-agent/web.cordis.yml` 组合了 web seam、真实的 `dsh-web-fetch-local` 提供方、`search: false` 的 `tool-web`,以及 `web-fetch-fixture-server.mjs`——一个固定端口(抓取的 URL 是录制 transcript(文本记录)的一部分)上的回环 HTTP fixture,提供包含命名实体、GFM 表格与嵌套格式的确定性 HTML。录制与无密钥回放都驱动真实的 HTTP 抓取与转换;固定住的工具结果就是 turndown 的输出,该场景同时固定 `web` header 类(`web_fetch` 的 schema 与指引)。 +此前缺失的无密钥 `web_fetch` 快照随本变更以 acp-agent 场景 `web-fetch` 落地:`examples/acp-agent/web.cordis.yml` 组合了 web seam、真实的 `dsh-web-fetch-local` 提供方、`search: false` 的 `tool-web`,以及 `web-fetch-fixture-server.mjs`——一个固定端口(抓取的 URL 是录制 transcript(文本记录)的一部分)上的回环 HTTP fixture(测试前置数据),提供包含命名实体、GFM 表格与嵌套格式的确定性 HTML。录制与无密钥回放都驱动真实的 HTTP 抓取与转换;固定住的工具结果就是 turndown 的输出,该场景同时固定 `web` header 类(`web_fetch` 的 schema 与指引)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml index e22645e088..54c59eb445 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md -2026-07-27-request-error-retry-action.md: 3057b9fa28cf203c9374930fe97421918b4c1a6f -2026-07-27-request-error-retry-action.zh.md: bcb4e592f0c3d86f896e279cf0e3ea400741a1bb +2026-07-27-request-error-retry-action.md: 18ae9bc4ba26d1ad3cb7d1328d9e9d3e8de8377c +2026-07-27-request-error-retry-action.zh.md: 5b36a38b6baed9b954fb04bca9e49c24edf9a969 diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md index 3057b9fa28..18ae9bc4ba 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md @@ -14,7 +14,7 @@ Model-request recovery was decided inside `agent/request-error` but communicated The loop reads the action after the waterfall settles, closes the failed turn, and opens one retry turn from durable history. It rechecks the turn signal when consuming the action, so cancellation or disposal during recovery prevents the retry even if a listener returns it afterward. A thrown recovery never produces an action. -`Agent` and `ReactLoopAgent` expose no `retry()` method. Ordinary new work enters through `send()` and its `followup()`, `steer()`, and `inject()` presets; only a handled model-request failure can open a promptless retry turn. +`Agent` and `ReactLoopAgent` expose no `retry()` method. Ordinary new work enters through `followup()`, `steer()`, and `inject()`; only a handled model-request failure can open a promptless retry turn. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md index bcb4e592f0..5b36a38b6b 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md @@ -12,13 +12,13 @@ Status: implemented `agent/request-error` 返回 `RequestErrorAction`,其中负责处理的动作是 `{ kind: 'retry' }`;默认的 `undefined` 会让失败轮次保持终态。不拥有该失败的监听器调用 `next()`。拥有该失败的监听器执行所有需要等待的修复,然后直接返回重试动作而不继续委托。 -waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久历史开启一个重试轮次。循环在使用该动作时会再次检查轮次信号,因此即使监听器随后返回重试动作,恢复期间发生的取消或资源释放仍会阻止重试。抛出异常的恢复不会产生动作。 +waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久历史开启一个重试轮次。循环在使用该动作时会再次检查轮次信号,因此即使监听器随后返回重试动作,恢复期间发生的取消或 dispose(资源释放)仍会阻止重试。抛出异常的恢复不会产生动作。 -`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `send()` 及其 `followup()`、`steer()` 和 `inject()` 预设进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 +`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `followup()`、`steer()` 和 `inject()` 进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 ## 曾考虑的替代方案 -**保留 `Agent.retry()` 作为恢复命令。** 运行时防护检查可以将该命令限制在请求错误窗口内,但接口仍会暴露一个没有生产消费方的空闲无提示词再运行操作,循环也仍需通过可变的旁路状态恢复已经由 waterfall 决定的结果。 +**保留 `Agent.retry()` 作为恢复命令。** 运行时防护检查可以将该命令限制在请求错误窗口内,但接口仍会暴露一个没有生产消费方的空闲无提示词再运行操作,循环也仍需通过可变的旁路状态取回已由 waterfall 承载的决策。 **返回显式终态动作。** `undefined` 已经表示 waterfall 未处理时的默认值,并可直接通过 `next()` 组合。再添加一个 `{ kind: 'fail' }` 值不会提供不同的行为或归属信息。 @@ -26,4 +26,4 @@ waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久 恢复归属、异步修复和重试决策共用一条类型化返回路径。活跃 agent 接口与具体循环不再具有空闲无提示词再运行能力和重试窗口状态。调用方如果不提交后续提示词,就无法重启任意失败的非请求工作;瞬时策略与上下文溢出策略则保留编号重试轮次、从持久历史重建、有限的策略私有预算和取消优先级。 -聚焦的 agent-loop 测试固定了重试链、未处理失败保持终态、恢复失败和取消竞态。llm-retry 与 compact-basic 测试套件固定其策略自有的动作返回,而 ACP、goal-session 和 plan-mode 集成测试固定后继轮次承接。 +聚焦的 agent-loop 测试固定了重试链、未处理失败保持终态、恢复失败和取消竞态。llm-retry 与 compact-basic 测试套件固定其策略自有的动作返回,而 ACP(Agent Client Protocol)、goal-session 和 plan-mode 集成测试固定后继轮次承接。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml index 7505370b2b..6c6ead17b7 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.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/simplification/2026-07-28-local-json-tree-renderer.md 2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c -2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68 +2026-07-28-local-json-tree-renderer.zh.md: f3a9faa5f9de264d0b7eadac631f7f4afff166b2 diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md index 221880391d..f3a9faa5f9 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md @@ -1,4 +1,4 @@ -# Agent Note:本地 JSON 树渲染器 +# Agent Note: 本地 JSON 树渲染器 Status: implemented diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index 75e8ca8d37..b4df206f13 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: fc76667924ec839301aad993efd996112c9a6b09 -2026-07-28-remove-synthetic-log-only-turns.zh.md: 7520c33e2219c5fe7ab7d8da6312247e42cc69b0 +2026-07-28-remove-synthetic-log-only-turns.md: 720aa6bbe246ea960a61f6d6f67d36d1f85af08a +2026-07-28-remove-synthetic-log-only-turns.zh.md: 587165a58ae322192b9ed1a9b3312f61e0b21ce6 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index fc76667924..720aa6bbe2 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-remove-synthetic-log-only-turns.zh.md) The session store exposed `appendOutOfBand()` so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in `turn/start` and `turn/end`, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update. -That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Idle context already uses the same capability by appending `user/message` between turns. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. +That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. The generic seam also duplicated domain policy. Its marker map said which plugin events were eligible, while the title capability already owned cancellation, liveness, and stale-result rules. Replacing it with another generic or title-specific append wrapper would preserve the same type indirection for two literal event types. @@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compact/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission. -A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and context records in a default fork while still rejecting a prefix cut through active execution. +A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution. The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 7520c33e22..587165a58a 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -8,7 +8,7 @@ Status: implemented 会话存储曾暴露 `appendOutOfBand()`,让插件可以在没有 agent(智能体)轮次运行时发布延迟到达的纯日志事件。该方法会用 `turn/start` 和 `turn/end` 包住事件,再将其刷写。这保留了「每个持久事件都必须位于轮次内」的旧规则,却让同一个标识符既表示模型循环执行,又表示仅持久化更新。 -引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。空闲上下文早已采用同一机制,在轮次之间追加 `user/message`。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 +引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 通用 seam 还重复了领域策略。它的标记映射说明哪些插件事件符合条件,而标题功能本就拥有取消、活跃性和陈旧结果处理规则。改用另一个通用或标题专属追加包装层,仍会为两个字面量事件类型保留同一层类型间接性。 @@ -20,7 +20,7 @@ Status: implemented 标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compact/* { turn: null }` 标记对,但会显式 flush 已闭合的尝试,因为 `/compact` 承诺在释放排队提示词接纳预留前完成持久化。 -会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和上下文记录,同时仍拒绝在活跃执行过程中截断前缀。 +会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。 历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。 diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml new file mode 100644 index 0000000000..3245b307e5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.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/simplification/2026-07-30-private-agent-send.md +2026-07-30-private-agent-send.md: 43353c309f98eab1bff91b8fbe0c9cfaa2bbc69b +2026-07-30-private-agent-send.zh.md: 49f7c999ec60c7305bdca570a4d59039c7c1923b diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md new file mode 100644 index 0000000000..43353c309f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md @@ -0,0 +1,27 @@ +# Agent Note: Keep agent routing private + +Status: implemented + +English | [中文](2026-07-30-private-agent-send.zh.md) + +## Problem + +The public `Agent.send()` method exposed the concrete loop's routing matrix even though production callers use only the semantic `followup()`, `steer()`, and `inject()` operations. Its fourth combination, `next-turn` with `wakeup: false`, had no consumer beyond tests. Keeping that latent capability public also required alternate `Agent` implementations and test fakes to accept implementation-level routing policy. + +## Decision + +`Agent` exposes `followup()`, `steer()`, and `inject()` as its complete delivery contract. `ReactLoopAgent` keeps a private `send()` helper that shares routing mechanics among those methods, while `SendTarget` and `SendOptions` are no longer exported from `dsh-agent`. + +The public interface cannot queue a turn without waking the driver. A follow-up always requests execution, steering requests the nearest step, and injection supplies model-facing context without requesting execution. This partially supersedes the public-surface portion of the [unified delivery decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) while retaining its internal routing and unified `user/message` representation. + +## Alternatives considered + +**Keep the routing matrix public.** This preserves the unused quiet-queue combination, but exposes mechanism instead of caller intent and imposes it on every alternate driver. + +**Add a public quiet-queue method.** A named method would be clearer than raw routing flags, but no production workflow currently needs work that remains parked until an unrelated delivery wakes it. + +## Consequences + +Plugins choose among three semantic operations instead of constructing routing options. Alternate drivers and structural test fakes implement a smaller contract, and the Cordis API catalog no longer advertises `send`, `SendTarget`, or `SendOptions`. + +The removed quiet-queue capability can return only with a named consumer and explicit lifecycle semantics. `cancel({ keepInbox: true })` still preserves work already pending through the supported delivery paths. diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md new file mode 100644 index 0000000000..49f7c999ec --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 将 agent 路由保留为私有实现 + +Status: implemented + +[English](2026-07-30-private-agent-send.md) | 中文 + +## 问题 + +公开的 `Agent.send()` 方法暴露了实体循环的路由矩阵,但生产调用方只使用语义明确的 `followup()`、`steer()` 和 `inject()` 操作。第四种组合,即 `next-turn` 配合 `wakeup: false`,除测试外没有消费方。将这项潜在能力保留为公开接口,还会迫使其他 `Agent` 实现和测试替身接受实现层的路由策略。 + +## 决策 + +`Agent` 将 `followup()`、`steer()` 和 `inject()` 作为完整的交付契约公开。`ReactLoopAgent` 保留私有的 `send()` 辅助方法,供这三个方法共用路由机制;`dsh-agent` 不再导出 `SendTarget` 和 `SendOptions`。 + +公开接口无法在不唤醒驱动器的情况下让一个轮次入队。`followup()` 始终请求执行,`steer()` 请求最近的步骤,`inject()` 则提供面向模型的上下文而不请求执行。本决策部分取代[统一交付决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)中关于公开接口的内容,同时保留其内部路由与统一的 `user/message` 表示。 + +## 曾考虑的替代方案 + +**让路由矩阵保持公开。** 这会保留未使用的无唤醒排队组合,但也会暴露机制而非调用方意图,并要求每个替代驱动器都支持该机制。 + +**添加公开的无唤醒排队方法。** 使用具名方法会比原始路由标志更清晰,但目前没有生产工作流需要让工作持续处于等待状态,直到无关的交付将其唤醒。 + +## 后果 + +插件从三种语义操作中选择,不再自行构造路由选项。其他驱动器和结构型测试替身只需实现更小的契约,Cordis API 目录也不再列出 `send`、`SendTarget` 或 `SendOptions`。 + +只有出现明确的消费方并定义显式的生命周期语义后,才能恢复已移除的无唤醒排队能力。`cancel({ keepInbox: true })` 仍会保留已通过受支持交付路径进入待处理状态的工作。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 2059841653..00c51f7dff 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: e2d821f3951af472ef1a13b7b6df88a3aa96a318 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: b55d4a271e0c5f2729222f4652fb0cb43e5cc9f9 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 6f39766810..e2d821f395 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -6,7 +6,7 @@ English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md ## Problem -Mid-turn steering is a host/agent-loop capability (`mode:'steer'`, durable `steering/message`). The Web product already locked the composer while a turn runs and never shipped a queue/steer menu, yet the client still threaded `'queue' | 'steer'` through the input machine, `conversation.send`, and locale keys, and rendered consumed steering as a badged 「插话」/「Interjection」 bubble. That left a half-built UI surface: an unused submit mode, a product label for a gesture users cannot perform, and e2e goldens that pinned chrome the product does not own. +Mid-turn steering is a host/agent-loop capability (`mode:'steer'`, a durable `user/message`). The Web product already locked the composer while a turn runs and never shipped a queue/steer menu, yet the client still threaded `'queue' | 'steer'` through the input machine, `conversation.send`, and locale keys, and rendered consumed steering as a badged 「插话」/「Interjection」 bubble. That left a half-built UI surface: an unused submit mode, a product label for a gesture users cannot perform, and e2e goldens that pinned chrome the product does not own. ## Decision @@ -14,7 +14,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: - `InputMachine` / `SessionInput` / `InputActions.submit` / hub `defaultSink` are queue-only; they always call `session.prompt(..., 'queue')`. - `ConversationService.send(text)` drops its mode argument and always queues. -- `MessageItem`'s `steering` arm still folds durable `steering/message` content into a plain right-aligned bubble (no badge, no user IconActions) so external/host steers stay visible on replay. +- Durable steer content renders as a plain right-aligned bubble (no badge, no user IconActions) so external/host steers stay visible on replay. - Delete `message.steering` locale strings and the unused badge CSS. - The web steering e2e still POSTs `mode:'steer'` over `/api/session.prompt` and asserts durable + model-visible obedience; it no longer expects interjection chrome. Update [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) fact lines to match. @@ -22,17 +22,20 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: **Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation. -**Hide `steering/message` from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble. +**Hide durable steer `user/message` content from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble. **Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach. ## Consequences -- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. -- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. -- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. +- **Superseded in part.** Decision bullets 1 and 3 through 5 no longer describe master: composer steering shipped later, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) owns its caption. The current facts follow. +- Host steering ownership is unchanged: agent-loop drain, session events, and the wire mode remain load-bearing for ACP, automation, and non-Web clients. +- `ConversationService.send(text)` still takes no mode and always queues; the composer's Steer gesture uses `session.prompt(mode: 'steer')` instead. +- Durable steer `user/message` content still folds into the transcript, so an externally submitted steer stays truthful on replay. It now carries the interjection caption instead of rendering as a bare bubble. +- Non-user next-step items (`agent.inject` context: approval notices, task completion, attached snapshots) broadcast with the `context` placement and never render as pending steering bubbles; they stay invisible until claimed as durable `user/message` context cards. ## Testing -- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. -- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). +- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, the MessageItem steering arm, InputBar submit. +- `apps/web/tests/steering.e2e.ts` keyless replay plus its goldens, which pin the caption. +- `packages/host/apiproxy` `session/queue` projection test asserts user-origin next-step items stay `steering` while plugin-origin items land as `context`. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index f127bd5700..b55d4a271e 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -中途 steering 是 host/agent-loop 能力(`mode:'steer'`、持久 `steering/message`)。Web 产品已在 turn 运行中锁定 composer,且从未交付排队/steer 菜单,但客户端仍把 `'queue' | 'steer'` 穿进 input machine、`conversation.send` 与 locale 键,并把已消费的 steering 渲染成带「插话」/「Interjection」徽章的气泡。这留下半成品 UI:用不到的提交 mode、用户做不到的手势却有产品文案,以及把产品并不拥有的 chrome 钉死在 e2e golden 上。 +中途 steering 是 host/agent-loop 能力(`mode:'steer'`、持久 `user/message`)。Web 产品已在 turn 运行中锁定 composer,且从未交付排队/steer 菜单,但客户端仍把 `'queue' | 'steer'` 穿进 input machine、`conversation.send` 与 locale 键,并把已消费的 steering 渲染成带「插话」/「Interjection」徽章的气泡。这留下半成品 UI:用不到的提交 mode、用户做不到的手势却有产品文案,以及把产品并不拥有的 chrome 钉死在 e2e golden 上。 ## 决策 @@ -14,7 +14,7 @@ Status: implemented - `InputMachine`/`SessionInput`/`InputActions.submit`/hub `defaultSink` 仅 queue;始终调用 `session.prompt(..., 'queue')`。 - `ConversationService.send(text)` 去掉 mode 参数,始终排队。 -- `MessageItem` 的 `steering` 分支仍把持久 `steering/message` 内容折成右对齐普通气泡(无徽章、无用户 IconActions),以便外部/host steer 在回放时仍可见。 +- 持久 steer 内容渲染为右对齐普通气泡(无徽章、无用户 IconActions),以便外部/host steer 在回放时仍可见。 - 删除 `message.steering` locale 字符串与未使用的徽章 CSS。 - web steering e2e 仍通过 `/api/session.prompt` POST `mode:'steer'`,并断言持久化与模型可见服从;不再期望插话 chrome。同步更新 [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) 中的事实行。 @@ -22,17 +22,20 @@ Status: implemented **整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。 -**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。 +**在 transcript 中隐藏持久 steer `user/message` 内容。** 外部客户端 steer 时回放会失真,因此改为普通气泡。 **保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。 ## 后果 -- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 -- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 -- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 +- **部分被取代。** 决策中的第 1 条和第 3 至 5 条已经不再描述 master:composer steering 后来已经交付,[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)负责定义其标注。下面列出当前事实。 +- host 侧 steering 的归属未变:agent-loop 排空、session 事件与线缆 mode 对 ACP、自动化和非 Web 客户端仍然必要。 +- `ConversationService.send(text)` 仍然不接 mode,始终排队;composer 的 Steer 手势改走 `session.prompt(mode: 'steer')`。 +- 持久 steer `user/message` 内容仍然折叠进 transcript,因此外部提交的 steer 会如实出现在回放中。它现在带有插话标注,而不是无标识气泡。 +- 非用户来源的 next-step 项(`agent.inject` 上下文:审批通知、任务完成、附加快照)以 `context` placement 广播,绝不渲染为待处理 steering 气泡;领取为持久 `user/message` context card 前保持不可见。 ## 测试 -- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 -- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。 +- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支、InputBar submit。 +- `apps/web/tests/steering.e2e.ts` 无密钥回放及其黄金基线,后者会检查插话标注。 +- `packages/host/apiproxy` 的 `session/queue` 投影测试断言用户来源的 next-step 项保持 `steering`,而插件来源的项落入 `context`。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml new file mode 100644 index 0000000000..344dd2bf6c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.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/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md +2026-08-04-drop-windows-powershell-picker-fallback.md: 619afd31d9ec78cdb8565e29fa942b7db8749365 +2026-08-04-drop-windows-powershell-picker-fallback.zh.md: e14904db46a955d4cf40da195a39bf62cbef96ff diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md new file mode 100644 index 0000000000..619afd31d9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md @@ -0,0 +1,38 @@ +# Agent Note: Drop the Windows PowerShell picker fallback + +Status: implemented + +English | [中文](2026-08-04-drop-windows-powershell-picker-fallback.zh.md) + +## Problem + +The win32 branch of the native directory picker kept a two-tier PowerShell fallback under the koffi `IFileOpenDialog` child process: `pwsh.exe` first, then `powershell.exe` (Windows PowerShell 5.1), both running the same WinForms script with a `SetProcessDPIAware` opt-in. The chain existed to keep a working chooser when the koffi tier was "unavailable", but every trigger it plausibly protected was a failure of our own packaging or deployment, not of the operating system: + +- koffi's native binary ships as an ordinary optional dependency (`@koromix/koffi-win32-x64`, no install script); a host that installs the package at all has the binary, and a host that cannot install it fails the package install loudly — the fallback code never loads either. +- "Ancient Windows" cannot occur: the Node versions this repo supports run on Windows generations far newer than the Vista-era `IFileOpenDialog` ABI the dialog needs. +- A koffi/COM defect crashes only the dialog child process (crash isolation); the correct response to our own bug is a surfaced failure, not a silent downgrade to a legacy dialog. + +The chain also cost real complexity: two spawn tiers running one identical script, a fallback trigger widened from `ENOENT` to any pwsh failure to close the PowerShell 6 (no WinForms) regression, a triple-miss `AggregateError` carrying all three causes, and per-tier abort re-checks. The seam already owns the only fallback that matters — the `browse` backend at the composition level, chosen once at boot by `directory-picker-auto`. + +## Decision + +The win32 tier is exactly the koffi `IFileOpenDialog` child process; any failure surfaces as-is with no fallback. The PowerShell chain — the `pwsh` → Windows PowerShell 5.1 cascade, the DPI-corrected WinForms script, the `AggregateError` aggregation — is deleted, and `pickNativeDirectory`'s win32 branch is a single call. `dsh-native-command` remains a dependency for the POSIX tiers. + +The fallback criterion the rest of the package already followed now applies uniformly: a fallback tier exists only for tools the OS/desktop environment provides and may omit (`zenity` → `kdialog` on Linux, which the boot-time probe also samples); tools our own package ships (`koffi`) fail loud. macOS `osascript` stays fallback-free as before. + +This change consolidates and deletes the pwsh-first DPI picker-fix note: its decision is fully reversed here, and its preserved rationale no longer guides future work on a koffi-only tier. What it kept that was real: PowerShell 7 renders the modern `IFileDialog`-based folder picker where 5.1's `FolderBrowserDialog` is hardwired to the legacy `SHBrowseForFolder` tree; the script's `SetProcessDPIAware` corrected the spawn's system-DPI ceiling; the pwsh→5.1 hop existed because a resolvable PowerShell 6 has no WinForms (exit 1, not `ENOENT`). Its rejected alternatives (requiring PowerShell 7, importing `resolvePwshPath`, setting DPI awareness in the harness process) are moot with the chain gone. + +## Alternatives considered + +**Keep the chain but drop the pwsh quality tier (`koffi` → Windows PowerShell 5.1).** Rejected: the remaining tier still defends our own packaged dependency, still costs the script, the widened trigger, and the aggregation, and still hides our own vtable/COM defects behind a legacy dialog. The criterion "fallback only for externally provided tools" admits no Windows tier at all. + +**Keep the chain as-is.** Rejected: it was the only two-level runtime fallback in the picker surface, its triggers were deployment-side failures that fail loud anyway, and it degraded a failed pick into an `AggregateError` whose most actionable entry was a PowerShell host. + +**Fall back to `browse` at runtime when the native pick fails.** Rejected: the seam's flow holes are `single`-kind and the `-auto` composition already picks one backend at boot; a runtime cross-kind hop would double-mount both backends and blur the capability boundary. + +## Consequences + +- The win32 picker's failure surface is one error from one tier; callers see the real cause (koffi load failure, COM refusal, dialog crash) instead of a chain-aggregated error. +- `pwsh`/`powershell.exe` are no longer invoked by this package; the WinForms script, its `SetProcessDPIAware` correction, and the `-STA` flags are gone with them. +- Tests shrink accordingly: the pwsh/5.1 cascade and triple-miss cases are replaced by one "failure surfaces with no fallback" case; the default-adapter test now drives the Linux tier. +- Reintroduction condition: a future win32 mechanism outside our packaging chain (a system-provided dialog host we do not ship) would justify a single fallback tier under the same criterion. diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md new file mode 100644 index 0000000000..e14904db46 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md @@ -0,0 +1,38 @@ +# Agent Note:删除 Windows PowerShell 选择器回退 + +Status: implemented + +[English](2026-08-04-drop-windows-powershell-picker-fallback.md) | 中文 + +## Problem + +原生目录选择器的 win32 分支在 koffi `IFileOpenDialog` 子进程之下保留了一条两级 PowerShell 回退:先 `pwsh.exe`,再 `powershell.exe`(Windows PowerShell 5.1),两者运行同一个带 `SetProcessDPIAware` 开关的 WinForms 脚本。该链的存在是为了在 koffi 层"不可用"时仍能给出一个可用的选择器,但它可能保护的每一个触发条件都是我们自己打包或部署的失败,而不是操作系统的: + +- koffi 的原生二进制作为普通 optional 依赖(`@koromix/koffi-win32-x64`,无 install script)分发;能装上该包的宿主就一定有二进制,装不上的宿主会在安装期大声失败——回退代码同样不会加载。 +- "上古 Windows"不可能出现:本仓库支持的 Node 版本运行在远比 Vista 时代 `IFileOpenDialog` ABI 新的 Windows 世代上。 +- koffi/COM 缺陷只崩对话框子进程(crash isolation);对我们自己 bug 的正确反应是上报失败,而不是静默降级到旧版对话框。 + +这条链还付出了真实的复杂度:两个 spawn 层运行同一脚本、把回退触发从 `ENOENT` 拓宽为 pwsh 的任何失败以关闭 PowerShell 6(无 WinForms)回归、携带全部三个原因的三连败 `AggregateError`,以及每层的 abort 重检。seam 早已拥有唯一重要的回退——组合层面的 `browse` 后端,由 `directory-picker-auto` 在启动时选择一次。 + +## Decision + +win32 层恰好就是 koffi `IFileOpenDialog` 子进程;任何失败原样上报,无回退。PowerShell 链——`pwsh` → Windows PowerShell 5.1 级联、DPI 修正的 WinForms 脚本、`AggregateError` 聚合——被删除,`pickNativeDirectory` 的 win32 分支成为单次调用。`dsh-native-command` 仍为 POSIX 层保留依赖。 + +本包其余部分早已遵循的回退判据现在统一适用:回退层只存在于操作系统/桌面环境提供且可能缺失的工具(Linux 的 `zenity` → `kdialog`,启动探针同样采样它们);我们自己打包的工具(`koffi`)失败即大声报错。macOS `osascript` 与之前一样保持无回退。 + +本次变更合并并删除了 pwsh 优先的 DPI 选择器修复 Note:其决策在此被完全反转,其保留的 rationale 对只含 koffi 的层不再指导未来工作。其中真实的部分:PowerShell 7 呈现基于 `IFileDialog` 的现代文件夹选择器,而 5.1 的 `FolderBrowserDialog` 被硬连到旧版 `SHBrowseForFolder` 树;脚本的 `SetProcessDPIAware` 修正了 spawn 的系统 DPI 上限;pwsh→5.1 的跳转存在是因为可解析的 PowerShell 6 没有 WinForms(退出码 1,而非 `ENOENT`)。其被拒绝的替代方案(要求 PowerShell 7、导入 `resolvePwshPath`、在 harness 进程设置 DPI 感知)随链删除而失去意义。 + +## Alternatives considered + +**保留链但去掉 pwsh 质量层(`koffi` → Windows PowerShell 5.1)。** 拒绝:剩下的层仍在为我们自己打包的依赖辩护,仍要付出脚本、拓宽的触发与聚合的代价,仍会把我们自己的 vtable/COM 缺陷藏到旧版对话框后面。"仅对外部提供的工具回退"的判据不接受任何 Windows 层。 + +**原样保留链。** 拒绝:它是选择器面上唯一的二级运行时回退,其触发条件是本就大声失败的部署侧失败,并且它把失败的 pick 降级成一个最具可操作性的条目是 PowerShell 宿主的 `AggregateError`。 + +**原生 pick 失败时在运行时回退到 `browse`。** 拒绝:seam 的流程洞是 `single` kind,`-auto` 组合已在启动时选择一个后端;运行时跨 kind 跳转会双挂两个后端并模糊能力边界。 + +## Consequences + +- win32 选择器的失败面是来自单一层的一个错误;调用方看到真实原因(koffi 加载失败、COM 拒绝、对话框崩溃),而不是链式聚合的错误。 +- 本包不再调用 `pwsh`/`powershell.exe`;WinForms 脚本、其 `SetProcessDPIAware` 修正与 `-STA` 标志随之消失。 +- 测试相应缩减:pwsh/5.1 级联与三连败用例被一个"失败原样上报、无回退"用例取代;默认适配器测试改驱动 Linux 层。 +- 重新引入条件:未来出现在我们打包链之外的 win32 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。 diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml index e118ff7330..d64cedbdc3 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-11-property-based-testing.md 2026-06-11-property-based-testing.md: a1bd4147a26a3d562899310e238096939fc2d01a -2026-06-11-property-based-testing.zh.md: 0e1934a24fcf22442420a664c9824bb74c0fe7f7 +2026-06-11-property-based-testing.zh.md: 062b2df76597ba16a7ad0682d57b35e54a07d9bd diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md index 0e1934a24f..062b2df765 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -引入 `fast-check`(作为根 devDependency),在每个协议形态的包(package)中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付。属性测试套件仅在常规的 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) +引入 `fast-check`(作为根 devDependency),在每个协议形态的包中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付。属性测试套件仅在常规的 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) - **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无此类分片时默认为 `{kind:'stop'}`。 - **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。 @@ -23,7 +23,7 @@ Status: implemented - 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁发生。 - **它已经带来回报:** BlockAssembler 流发现了一个真实 bug——同一索引处重复的 `block-end` 会改写已经完成的块。现已修复(首次关闭优先,与现有迟到项规则一致),并加入专用回归测试。 -- 属性测试因超时而 flake 是一个发现,不应通过重试消除。循环属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 +- 属性测试因超时而 flake 是一个发现,不应通过重试消除。agent loop(智能体循环)的属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 - 属性测试是对示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index f126d6a85b..fe97a7b717 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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/testing/2026-06-19-acp-snapshot-tests.md 2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f -2026-06-19-acp-snapshot-tests.zh.md: 5c239d2c2589708050cd6199231ec5047f225107 +2026-06-19-acp-snapshot-tests.zh.md: e292dbf3bf3c4c5198dc77d122bb6b8c36014ebf diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 5c239d2c25..e292dbf3bf 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -单元测试不会覆盖组装后的完整 agent(智能体)子进程及其 ACP(Agent Client Protocol)自动化线协议,而真实 API 测试不具确定性且受密钥门控。因此,即使单元覆盖率为绿色,Loader 接线、后端行为和协议输出仍可能回归,[默认导出事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)已经证明了这一点。 +单元测试不会覆盖组装后的完整 agent(智能体)子进程及其 ACP(Agent Client Protocol)自动化协议格式,而真实 API 测试不具确定性且受密钥门控。因此,即使单元测试覆盖率检查通过,Loader 接线、后端行为和协议输出仍可能回归,[默认导出事故复盘(postmortem)](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)已经证明了这一点。 -全 transcript(文本记录)测试的阻塞因素在于模型:agent 的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture(测试前置数据)的确定性兼得。 +完整 transcript(文本记录)测试的阻塞因素在于模型:agent 的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture(测试前置数据)的确定性兼得。 -本 Agent Note(agent 决策记录)记下了新增第三层测试——**快照测试**——的决策,以及让它具备确定性、在 CI 中无需密钥、且维护成本低廉的设计选择。 +本 Agent Note 记下了新增第三层测试——**快照测试**——的决策,以及让它具备确定性、在 CI 中无需密钥、且维护成本低廉的设计选择。 ## 决策 @@ -18,9 +18,9 @@ Status: implemented ### fixture 即持久化的会话 JSONL -每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当重放来源和行为预期输出。 +每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当回放来源和行为预期输出。 -每个签入仓库的会话格式 fixture 都使用规范的打包物理布局。覆盖所有行类型的场景从一份独立的真实录制机械派生;测试要求它包含每一种打包存储行类型,并在两份 fixture 解码后逐事件精确相等;随后,普通重放与日志比较会证明组装后的进程能够消费并复现该布局。 +每个签入仓库的会话格式 fixture 都使用规范的打包物理布局。覆盖所有行类型的场景从一份独立的真实录制机械派生;测试要求它包含每一种打包存储行类型,并在两份 fixture 解码后逐事件精确相等;随后,普通回放与日志比较会证明组装后的进程能够消费并复现该布局。 ### 回放从日志推导模型脚本 @@ -44,42 +44,42 @@ Status: implemented ### 录制采集日志;无密钥回放需要无提供方的配置 -记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值;符合条件的分片连续段仍使用默认的打包存储行。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。 +记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交回放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值;符合条件的分片连续段仍使用默认的打包存储行。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责回放。 -重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实适配器,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](../../archived/testing/2026-07-04-single-source-acp-replay-config.md)。 +回放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实适配器,同时保留实际组合。记录使用普通配置和由 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发真实调用。参见[单一来源配置 Agent Note](../../archived/testing/2026-07-04-single-source-acp-replay-config.md)。 ### 两个表面:归一化后比对 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: -1. **stdout transcript**——自动化客户端收到的、经过 framing 的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。 -2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema sidecar;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源,因此每个不同的 sidecar 版本只提交一次。fixture 保护会拒绝重复的 sidecar 内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其 sidecar 派生模型行为。 +1. **stdout transcript**——自动化客户端收到的、分帧后的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。 +2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为回放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema 伴随文件;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源,因此每个不同的伴随文件版本只提交一次。fixture 保护会拒绝重复的伴随文件内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其伴随文件派生模型行为。 -两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。 +两个表面互补:stdout 覆盖精简的自动化协议格式,JSONL 覆盖协议格式有意省略的 loop、工具和 boundary 结构。 -规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是符合协议格式的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 ### 隔离:当前靠归一化,后续可加沙箱 -工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,沙箱执行器可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。 +工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发回放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的清理操作无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,沙箱执行器可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。 ### 回放插件是独立的包 -`@deepseek-ai/dsh-llm-replay` 是一个支撑包(package),而非示例本地的胶水代码。它通过用从 JSONL 重建的流短路 `llm/stream` 来替换真实适配器,其包级放置使回放逻辑处于正常覆盖率门禁之下。 +`@deepseek-ai/dsh-llm-replay` 是一个支撑包,而非示例本地的胶水代码。它通过用从 JSONL 重建的流短路 `llm/stream` 来替换真实适配器,其包级放置使回放逻辑处于正常覆盖率门禁之下。 ### 两个子命令,回放在默认门禁中 -`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的会话日志与 stdout 预期输出。同一无密钥门禁会通过 `session` header 发现仓库中的 JSONL,并拒绝与共享编解码器的规范打包表示不同的任何 fixture。缺少 fixture 时会响亮失败。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 +`pnpm run test:snapshot` 无需密钥即可回放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的会话日志与 stdout 预期输出。同一无密钥门禁会通过 `session` header 发现仓库中的 JSONL,并拒绝与共享编解码器的规范打包表示不同的任何 fixture。缺少 fixture 时会明确报错。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生回放。fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 ## 曾考虑的替代方案 - **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 -- **在每个类别 pin 旁复制两个请求头 sidecar**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 +- **在每个类别 pin 旁复制两个请求头伴随文件**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 -本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 +本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 97313f0333..774a4d8851 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md 2026-06-19-real-api-e2e-ci.md: 935664fd01df4844ee19be7b4f2f297ebf5bd29b -2026-06-19-real-api-e2e-ci.zh.md: 9c10614f5a7e6b38f6850b29fab87d0e09806c5f +2026-06-19-real-api-e2e-ci.zh.md: 4245cc9a3872cf2ab27633db5b23fc466d793934 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 9c10614f5a..4245cc9a38 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实 ACP 客户端会话却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。 +根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事故复盘(postmortem)](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实 ACP 客户端会话却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对线上 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。 默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 -本 Agent Note(agent 决策记录)记下了新增**第二条消费 secret 的工作流**以在 CI 中运行真实 API 套件的决策;由于向未来可能公开的仓库引入第一个 CI secret 属于安全/隔离决策,本文也记录其依赖的威胁模型,以及仓库公开时需要做出的变更。 +本 Agent Note 记下了新增**第二条消费 secret 的工作流**以在 CI 中运行真实 API 套件的决策;由于向未来可能公开的仓库引入第一个 CI secret 属于安全/隔离决策,本文也记录其依赖的威胁模型,以及仓库公开时需要做出的变更。 ## 决策 @@ -26,7 +26,7 @@ ci.yml 的价值在于它无密钥、可 fork、始终为绿:任何贡献者 ### 触发条件:仅限可信事件 -`workflow_dispatch` + `push` 到 `main`/`master` + 每夜 `schedule`(`17 0 * * *`,即北京时间 08:17)+ `pull_request`。push 提供合并后信号;schedule 捕捉外部 API 漂移;dispatch 是手动逃生通道;可信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性中描述的更大密钥暴露面。 +`workflow_dispatch` + `push` 到 `main`/`master` + 每夜 `schedule`(`17 0 * * *`,即北京时间 08:17)+ `pull_request`。push 提供合并后信号;schedule 捕捉外部 API 漂移;dispatch 是手动逃生通道;可信 PR 获得合并前门禁。该合并前信号有意接受 § 安全性中描述的更大密钥暴露面。 ### 不可信 PR 的门禁 @@ -41,7 +41,7 @@ Dependabot 子句基于 PR **作者**(`pull_request.user.login`)而非 `gith 该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 扣留机制)。没有该门禁,fork 仍然无法读取密钥;只是会遇到令人困惑的 preflight 硬失败并浪费计算资源。 -### Preflight:大声失败,绝不虚假为绿 +### Preflight:明确失败,绝不虚假报绿 由于 job 仅在 secret 应当存在的可信事件上运行,preflight 是一个无条件的存在性检查:密钥为空→`exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全地作为门禁的关键。没有它,被删除/重命名/错误配置的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。该守卫将「secret 缺失」从不可见的虚假通过转化为可见的失败。(其正确性已在实际中验证:secret 存在之前的运行恰好在此步骤失败。) @@ -51,14 +51,14 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 - **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 - **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 -- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(`vitest.e2e.config.ts` 存在时会加载)无法静默地将运行重定向到其他端点。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 - **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 ### 范围与运行时形态 job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。 -DeepSeek 原生 `web_search` 探测已注册但会跳过。实时 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元覆盖率仍会固定响应解析,但 CI 不会证明实时来源块的线协议形状。 +DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元测试仍会锁定响应解析行为,但 CI 不会验证线上端点返回的来源块协议格式。 ## 安全性 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index e6c04226f9..70d56bbca5 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md 2026-06-22-fork-child-replay-seed-boundary.md: ed3ec095bc14128f5ebc0a9188bc022ef97b1c8b -2026-06-22-fork-child-replay-seed-boundary.zh.md: 84cd56ccea69aab0246582512908b4a73ce3c36a +2026-06-22-fork-child-replay-seed-boundary.zh.md: 1d938cbf6c9c32a144d58fed48e552d85aa9c625 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 84cd56ccea..1d938cbf6c 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[逐会话快照重放 Agent Note(agent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。 +[逐会话快照回放 Agent Note](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本回放。它曾指出(§ 范围,最后一个项目符号),fork 快照「只是未来很容易添加的一项,并非键控缺口」。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。 subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。 @@ -22,7 +22,7 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla `SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 -`seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。) +`seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(做法与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。) ### 2. 两个持久化后端均完整往返 @@ -35,11 +35,11 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla `dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 `seedLength` 为 0,此操作是空操作,spawn 场景逐字节不变。 -这关闭了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)。 +这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)。 ## 曾考虑的替代方案 -- **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包(package)seam 处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。 +- **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包 seam 处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。 - **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」姿态)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一组小的、值得区分的修订),与事件词汇表的 `version` 不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index a99819223f..947e2da6eb 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md 2026-06-22-subagent-snapshot-replay.md: b8fefce5ff27b0cd3cfa2920b137e78cda0d696d -2026-06-22-subagent-snapshot-replay.zh.md: a673d6e5dd124986b827fcc6708db447090173c7 +2026-06-22-subagent-snapshot-replay.zh.md: e8bd8917a02e02f2ce89105eb64794876653574d diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index a673d6e5dd..e8bd8917a0 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录会话,并将规范化后的自动化线协议 + 重新持久化的会话日志与已提交预期输出进行 diff。大多数场景通过这条真实进程边界测试组装后的后端行为。 +快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放已记录会话,并将规范化后的自动化协议输出 + 重新持久化的会话日志与已提交预期输出进行 diff。大多数场景通过这条真实进程边界测试组装后的后端行为。 该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: - **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent(智能体)和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 - **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行并拥有自己的日志,因此子 agent 的 transcript(文本记录)被静默丢弃。 -这就是 [subagent seam Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 +这就是 [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 ## 决策 @@ -21,7 +21,7 @@ Status: implemented ### 1. 调用方会话 id 附着在模型请求上 -`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 赋值。适配器忽略它;`llm/stream` 监听器用它按发起会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session` 的 `SessionId`,因为后者所在包(package)导入了 `dsh-llm` 的 `Message`,反向导入会形成循环。两个类型等价,因此会话 id 赋值无需类型转换。将 brand 移到一个专用 ids 包属于独立工作,因为它会影响所有 id 导入。 +`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 赋值。适配器忽略它;`llm/stream` 监听器用它按发起会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session` 的 `SessionId`,因为后者所在包导入了 `dsh-llm` 的 `Message`,反向导入会形成循环。两个类型等价,因此会话 id 赋值无需类型转换。将 brand 移到一个专用 ids 包属于独立工作,因为它会影响所有 id 导入。 ### 2. 回放按首次调用顺序将活跃会话绑定到录制脚本 @@ -29,17 +29,17 @@ Status: implemented 活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定到脚本。取而代之的是**首次调用顺序**绑定:第一个发起任何模型调用的活跃会话认领第一份有序脚本(即父会话:`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。此后每个会话独立推进自己的游标。 -这种方式按谁在调用键控,而非按全局调用顺序。因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会快速失败报错(出现了未录制的 subagent),绝不会静默错误路由。 +这种方式按谁在调用键控,而非按全局调用顺序。因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会明确报错(出现了未录制的 subagent),绝不会静默错误路由。 -子 fixture(测试前置数据)按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破仅使退化碰撞具有确定性。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 +子 fixture(测试前置数据)按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 决胜规则仅用于让极端情况下的时间戳冲突获得确定顺序。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 ## 曾考虑的替代方案 -曾考虑但否决的方案是:**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确)。对当前的同步裁剪而言更简单,但将「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控则不会。 +曾考虑但否决的方案是:**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确)。对当前的同步实现而言更简单,但将「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控则不会。 ### 3. harness 收集所有日志,主会话优先 -`harvestSessionLogs` 递归收集 sessions 根目录下所有固定命名为 `session.jsonl` 的 transcript(JSONL 后端为每个父会话和子会话分别提供独立的项目/会话目录),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。 +`harvestSessionLogs` 递归收集 sessions 根目录下所有固定命名为 `session.jsonl` 的 transcript(JSONL 后端为每个父会话和子会话分别提供独立的项目/会话目录),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 包含多份日志;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持多个会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。 ### 4. 场景 @@ -54,5 +54,5 @@ Status: implemented - `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。 - `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。 -- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化种子边界,使 fork 子项重放能够正确路由](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。 +- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化 seed 边界以确保 fork 子会话回放正确路由](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。 - 进程外(ACP(Agent Client Protocol))subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index f5fc9ecef6..bb1ed0b43b 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md 2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a -2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f +2026-07-22-cross-platform-test-fixtures.zh.md: ce9c65106b904b4c16360c9cb2545b0316bd7120 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 43942ec046..ce9c65106b 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml index c66891f689..5f6b9c16d8 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.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/testing/2026-07-25-scriptable-llm-wire-fault-server.md 2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e -2026-07-25-scriptable-llm-wire-fault-server.zh.md: 35b27efa99b625fa3c815bbe58fef6a138477e55 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: 54604e2d8c019386d8f94d90d859bb08ce58b030 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md index 35b27efa99..54604e2d8c 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 可脚本控制的 LLM(大语言模型)协议层故障服务器 +# Agent Note: 可脚本控制的 LLM 协议层故障服务器 Status: implemented @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包(package),提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。 +`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包,提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时会明确报错;只有设置 `repeatLast` 才会重复最后一个行为。 请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI(命令行界面)的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml index 466071f552..f3676ba1ef 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.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/testing/2026-07-26-execa-for-test-subprocess-plumbing.md 2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 5ccadd93a182ba299be80d48881fe1c470a2d537 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md index 7027a8bde5..5ccadd93a1 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -16,11 +16,11 @@ Status: implemented ## 决定 -- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 -- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。 -- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。 +- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费方)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 +- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;本 Agent Note 涉及该文件的部分,仅是那份已成为死代码的 `.env` 解析器。 +- `llm-mock-server` 的 CLI(命令行界面)经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,固定错误消息的测试改为采用 `parseArgs` 自己的切分器文本。 - 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。 -- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 +- 那四个轮询循环改用 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 ## 曾考虑的替代方案 @@ -32,6 +32,6 @@ Status: implemented - 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 - 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 -- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 通道负责。 - execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 - mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index e36caa3f12..ea4966f81e 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md 2026-06-16-typed-event-schemas.md: 97a7d0c3787eb5556696e25a6c8b1bb75642aba9 -2026-06-16-typed-event-schemas.zh.md: c19f67c6ff058d42293ff2b6346630fe91c54dec +2026-06-16-typed-event-schemas.zh.md: d0c9ffb3640cb76f0c656e4ac841dc3817bcb86d diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index c19f67c6ff..d0c9ffb364 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -13,13 +13,13 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而非结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 2. **插件新增变体没有运行时契约。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否符合它所声明的形状——无论是在生产者处、持久化边界处还是重新加载时。 -由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化和插件边界拥有运行时 schema 而非被擦除的类型。 +由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化边界和插件边界拥有运行时 schema 而非被擦除的类型。 -本 Agent Note(agent 决策记录)界定该问题的范围,不提出具体实现。 +本 Agent Note 界定该问题的范围,不提出具体实现。 ## 为什么这不是一个持久化层的改动 -很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部修改。但它不是,原因在于一个结构性事实:**插件无法对 Zod schema 进行声明合并。** 声明合并是 TypeScript 编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,就需要一个**运行时注册表**,每个产出事件的包(package)向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible 接口。 +很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部修改。但它不是,原因在于一个结构性事实:**插件无法对 Zod schema 进行声明合并。** 声明合并是 TypeScript 编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,就需要一个**运行时注册表**,每个产出事件的包向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible 接口。 因此,真正的提案是:**用运行时 schema 注册表替换编译期的 merge-extensible-map 模式,范围覆盖整个仓库。** 这是一次核心词汇的重新设计。 @@ -53,7 +53,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 ### C. 为整个词汇建立运行时 schema 注册表(Zod 或 schemastery) 用运行时注册表替换 merge-extensible map,生产者向其贡献 schema,持久化/消费路径据此校验。 -- **优点**:持久化边界和插件 seam 处获得真正的运行时校验;单一真源;可支撑通用工具(自动生成文档、模糊测试、协议格式检查)。 +- **优点**:持久化边界和插件 seam 处获得真正的运行时校验;单一真源;可支撑通用工具(自动生成文档、模糊测试、协议格式(wire format)检查)。 - **缺点**:上述全部影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的易用性(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷举保证弱化(运行时变体在静态层面不可穷举)。 ## 提案 diff --git a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml index 83a2d4d788..e6fc787553 100644 --- a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md 2026-07-15-sdk-project-editing-architecture.md: 8335af516dbaa85f4adb85286f976ce9be2c9da8 -2026-07-15-sdk-project-editing-architecture.zh.md: bec39cc896887678b2d3f74832a9d13d7b354d6e +2026-07-15-sdk-project-editing-architecture.zh.md: 51f0c5e7f4f033d8149a7ae01aae7a8c4755b76c diff --git a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md index bec39cc896..51f0c5e7f4 100644 --- a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md @@ -6,11 +6,11 @@ Status: proposed ## 问题 -[开发者拥有的 SDK 工程](../feature/2026-07-14-sdk-developer-projects.md) 由 create 创建,可以通过 config 调整,并由 start 等命令构建和运行。初始创建、配置调整和编译运行都需要理解功能、功能选项、NPM 依赖、Cordis 配置项、环境变量、包管理器、本地插件和多个项目文件。如果读写项目的各个流程分别使用不同的解析协议,SDK 开发者流程会变得难以维护。 +[开发者拥有的 SDK 工程](../feature/2026-07-14-sdk-developer-projects.md) 由 create 创建,可以通过 config 调整,并由 start 等命令构建和运行。初始创建、配置调整和编译运行都需要理解功能、功能选项、NPM 依赖、Cordis 配置项、环境变量、包管理器、本地插件和多个项目文件。如果各个项目读写工作流分别使用不同的解析协议,SDK 开发者工作流会变得难以维护。 ## 提案 -SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照,`ProjectEditSession` 是唯一修改与提交边界;功能对象负责自身的功能选项、关系、资源贡献和现状识别;create 与 config 只编排各自的用户流程,并通过同一组领域操作修改工程。 +SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照,`ProjectEditSession` 是唯一修改与提交边界;功能对象负责自身的功能选项、关系、资源贡献和现状识别;create 与 config 只编排各自的用户工作流,并通过同一组领域操作修改工程。 结构化文件通过文档对象修改,一次性文本产物通过完整模板生成。问题由类型化对象表达,并使用 clack 交互。差异计算可以作为编辑会话的内部实现,但不成为要求调用方组装的公共执行协议。 @@ -18,26 +18,26 @@ SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照 | 名词 | 本文用词 | 含义 | |---|---|---| -| Feature | 功能 | SDK 人工策划和管理的产品单元;一项功能可以包含多个功能选项,并贡献多个 Cordis 配置项、NPM 依赖、环境变量占位和独占文件 | +| Feature | 功能 | 由 SDK 策划和管理的产品单元;一项功能可以包含多个功能选项,并贡献多个 Cordis 配置项、NPM 依赖、环境变量占位和独占文件 | | Feature option | 功能选项 | 一项功能内有限、可选择的实现或配置形状;根据功能规则可以固定、互斥或多选 | | Cordis plugin | Cordis 插件 | Cordis 加载的插件实现,通常由一个 NPM 包导出;它不是 `cordis.yml` 中的一项配置 | | Cordis config entry | Cordis 配置项 | `cordis.yml` 插件列表中的一项,通过 `id` 标识实例并通过 `name` 指向 Cordis 插件 | -| Cordis plugin config | Cordis 插件配置 | Cordis 插件公开的配置对象或配置结构;其中由功能拥有并更新的单个字段称为“配置键” | +| Cordis plugin config | Cordis 插件配置 | Cordis 插件公开的配置对象或配置结构;其中由功能拥有并更新的单个字段称为「配置键」 | | config key | 配置键 | Cordis 插件配置中的单个字段;功能只更新自己声明拥有的配置键,并保留未知配置键 | | npm dependency | NPM 依赖 | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 | | Feature requirement | 功能依赖 | 功能或功能选项通过 `requires` 声明的关系 | -## Package 边界 +## 包边界 -| Package | 责任 | 不负责 | +| 包 | 责任 | 不负责 | |---|---|---| -| `@deepseek-ai/dsh-helper` | 编辑会话、功能配置、工程模板渲染、包管理适配和 prompt 交互适配 | 启动 Cordis 应用或决定 create/config 的终端流程 | -| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`、进程生命周期、项目入口加载、config 流程和所属终端文案模板 | 直接解释功能定义或修改 YAML/JSON AST | +| `@deepseek-ai/dsh-helper` | 编辑会话、功能配置、工程模板渲染、包管理器适配和 prompt 交互适配 | 启动 Cordis 应用或决定 create/config 的终端工作流 | +| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`、进程生命周期、项目入口加载、config 工作流和所属终端文案模板 | 直接解释功能定义或修改 YAML/JSON AST | | `@deepseek-ai/create-sdk` | `npm create @deepseek-ai/sdk` 的参数、问题顺序、首次工程创建、安装收尾和所属终端文案模板 | 成为生成工程的运行时 NPM 依赖或提供库 API | -`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外;npm scoped initializer 约定要求 `npm create @deepseek-ai/sdk` 对应这个 package 名。该例外是仓库架构事实,不增加第三个开发者产品入口。 +`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外;npm scoped initializer 约定要求 `npm create @deepseek-ai/sdk` 对应这个包名。该例外是仓库架构事实,不增加第三个开发者产品入口。 -三个 package 只导出相邻层实际使用的最小入口,不提供 `src/*` 深路径。scripts 的库入口与构建配置子路径服务生成代码和项目构建配置,但开发者产品合同仍由 `dsh-sdk` 命令承担。 +三个包只导出相邻层实际使用的最小入口,不提供 `src/*` 深路径。scripts 的库入口与构建配置子路径服务生成代码和项目构建配置,但开发者产品契约仍由 `dsh-sdk` 命令承担。 ## 工程聚合与编辑会话 @@ -58,35 +58,35 @@ validate feature requirements and resource ownership ## 功能与资源所有权 -功能是一等行为对象。浅层基类实现 install、configure、enable、disable、required/requires 校验和共同状态识别;固定功能选项、互斥功能选项与可多选功能选项共享这些生命周期。只有资源贡献依赖项目上下文或需要自定义 round-trip 的功能才使用专用行为类,其余功能通过标准化数据声明真正不同的部分。 +功能是一等行为对象。浅层基类实现 install、configure、enable、disable、required/requires 校验和通用状态检查;固定功能选项、互斥功能选项与可多选功能选项共享这些生命周期。只有资源贡献依赖项目上下文或需要自定义 round-trip 的功能才使用专用行为类,其余功能通过标准化数据声明真正不同的部分。 每项功能贡献带稳定 key 的 Cordis 配置项、NPM 依赖、环境变量占位和独占文件。注册表初始化时拒绝不同功能声明同一个资源 key;同一功能的不同功能选项可以共享资源,并由该功能根据最终选项集合处理。 -Cordis 配置项是功能安装锚点。NPM 包名判断配置项所属的功能,配置项 ID 区分同一插件包的多个实例;只有 NPM 依赖而没有功能拥有的 Cordis 配置项时,该功能仍视为未安装。Cordis 配置项存在后,缺失 NPM 依赖、无法读取的 Cordis 插件配置或资源冲突会使功能进入不一致状态,config 命令显示诊断并拒绝猜测式修改。 +Cordis 配置项是功能安装锚点。NPM 包名用于确定配置项所属的功能,配置项 ID 区分同一插件包的多个实例;只有 NPM 依赖而没有功能拥有的 Cordis 配置项时,该功能仍视为未安装。Cordis 配置项存在后,缺失 NPM 依赖、无法读取的 Cordis 插件配置或资源冲突会使功能进入不一致状态,config 命令显示诊断并拒绝猜测式修改。 -同一功能选项只更新其声明拥有的配置键,保留未知键。替换功能选项会删除旧功能选项独占且仍可确认的资源;无法确认旧资源或发现独占文件被用户修改时,整个操作失败。 +配置同一功能选项时,只更新其声明拥有的配置键,保留未知键。替换功能选项会删除旧功能选项独占且仍可确认的资源;无法确认旧资源或发现独占文件被用户修改时,整个操作失败。 -## 问题与 workflow +## 问题与工作流 -问题由 TypeScript `Question<T>` 对象表达,默认值、校验、适用条件和类型留在同一个对象中。`PromptPort` 是领域层与终端库之间的唯一接口,helper 提供一份薄 `ClackPromptPort`;create 和 config 注入各自的命令行输入输出流,并在各自流程中决定取消、返回和收尾语义。 +问题由 TypeScript `Question<T>` 对象表达,默认值、校验、适用条件和类型留在同一个对象中。`PromptPort` 是领域层与终端库之间的唯一接口,helper 提供一个轻量的 `ClackPromptPort`;create 和 config 注入各自的命令行输入输出流,并在各自工作流中决定取消、返回和收尾语义。 -create 的有状态问题顺序留在一个向导中,config 的最终状态选择留在一个流程中。两者通过同一个功能配置器收集功能选项与专用输入,因此增加一项普通功能、功能选项或参数不要求同时修改两个入口。 +create 的有状态问题顺序留在一个向导中,config 的最终状态选择留在一个工作流中。两者通过同一个功能配置器收集功能选项与专用输入,因此增加一项普通功能、功能选项或参数不要求同时修改两个入口。 ## 项目文档与模板 -只有需要读取或修改的结构化文件拥有具体文档对象,包括 `package.json`、`cordis.yml`、`.env`、`.env.example`、根 `tsconfig.json` 和 pnpm workspace 文件。文档对象拥有解析、克隆、校验和序列化行为;具体类与模块分别使用 `*File` 和 `*-file.ts` 命名,业务层不直接操作 YAML/JSON AST,异常形状在所属文档边界 fail loud。 +只有需要读取或修改的结构化文件拥有具体文档对象,包括 `package.json`、`cordis.yml`、`.env`、`.env.example`、根 `tsconfig.json` 和 pnpm workspace 文件。文档对象拥有解析、克隆、校验和序列化行为;具体类与模块分别使用 `*File` 和 `*-file.ts` 命名,业务层不直接操作 YAML/JSON AST,异常结构会在所属文档边界明确报错。 -README、入口代码、构建配置、`.gitignore` 和其他一次性文本产物使用与真实文件一一对应的完整模板。CLI usage、创建结果与恢复提示、安装与重试指导以及默认 persona 等完整产品文案也由所属 package 的本地模板提供。 +README、入口代码、构建配置、`.gitignore` 和其他一次性文本产物使用与真实文件一一对应的完整模板。CLI(命令行界面)用法、创建结果与恢复提示、安装与重试指导以及默认 persona 等完整产品文案也由所属包的本地模板提供。 -helper 提供通用的数据类型化 `TextTemplate` 模板渲染器,调用 package 通过本地 asset URL 加载自己的模板。 +helper 提供通用的数据类型化 `TextTemplate` 模板渲染器,调用方包通过本地 asset URL 加载自己的模板。 -模板使用 Handlebars strict mode 与 `noEscape`,不进行自定义处理。文件对象负责把类型化数据值编码成目标语言文本;如果不希望插值,则源码以 `\{{model}}` 等转义形式输出下游。 +模板使用 Handlebars strict mode 与 `noEscape`,不进行自定义处理。文件对象负责把类型化数据值编码成目标语言文本;模板源码在必须原样输出下游字面量时,将插值转义为 `\{{model}}`。 ## 命令与运行边界 -scripts 支持 `dsh-sdk start/dev/build/config`。start 动态加载模块 target 并调用其命名入口;dev 在同一路径前增加 TypeScript 与本地 workspace 源码解析;build 调用工程安装的 tsdown;config 打开一个编辑会话并在 Review & Apply 后提交。typecheck 由生成工程直接执行 `tsc -b`。 +scripts 支持 `dsh-sdk start/dev/build/config`。start 动态加载模块 target 并调用其命名入口;dev 在同一路径前增加 TypeScript 与本地 workspace 源码解析;build 调用工程安装的 tsdown;config 打开一个编辑会话并在 Review & Apply 后提交。类型检查由生成工程直接执行 `tsc -b`。 -HMR 作为显式 Cordis 配置项由 dev 和 start 加载;它所需的 `node-addon-require-builtin` 由 scripts package 传递提供,不写入开发者工程的 `package.json`。 +HMR(热模块替换)作为显式 Cordis 配置项由 dev 和 start 加载;它所需的 `node-addon-require-builtin` 由 scripts 包传递提供,不写入开发者工程的 `package.json`。 dev/start 会执行开发者入口,在开发者代码中处理命令行参数、cwd,由开发者自行传入 `--model=<name>` 与 `--resume=<session-id>` 启动标准流程。 @@ -94,12 +94,12 @@ dev/start 会执行开发者入口,在开发者代码中处理命令行参数 create-sdk 保留隐藏的 `--link-workspace` 选项供 Harness 仓库开发和 e2e 使用。该选项可以被解析,但不出现在 help、公开 flag 清单或普通用户文档中,也不接收仓库路径参数;仓库根从正在执行的 create-sdk 模块位置向上确定。 -链接模式保持普通工程的文件形状。`@deepseek-ai/*` 指向 `packages/`,Cordis 相关 NPM 依赖指向 `vendor/`,共享底层 package 锚定到仓库实际使用的同一物理拷贝,避免 Cordis 类型合并产生多个模块类型定义。npm 使用 `file:`,pnpm 使用 `link:` 并关闭自动 peer 安装,Yarn 使用 `portal:` 与 resolutions;仓库 package 需要先构建。 +链接模式保持普通工程的文件形状。`@deepseek-ai/*` 指向 `packages/`,Cordis 相关 NPM 依赖指向 `vendor/`,共享底层包锚定到仓库实际使用的同一物理拷贝,避免 Cordis 类型合并产生多个模块类型定义。npm 使用 `file:`,pnpm 使用 `link:` 并关闭自动 peer 安装,Yarn 使用 `portal:` 与 resolutions;仓库包需要先构建。 ## 后续工作 - **可替换的 required 主干角色。** 当前 `spine` 以一个固定功能选项拥有整组实现,包含 SystemPrompt、LLMService 等。无法让开发者对其进行替换和切换,只能手工修改 Cordis 配置项。 -- **Service contract 与 package 声明。** 替换特定内建服务时,Cordis 插件目前无法通过 `provides` 元数据声明其提供的服务,因此 SDK 无法在开发阶段辅助配置,也无法在运行时检查兼容性。后续需要设计相应协议。 +- **服务契约与包声明。** 替换特定内建服务时,Cordis 插件目前无法通过 `provides` 元数据声明其提供的服务,因此 SDK 无法在开发阶段辅助配置,也无法在运行时检查兼容性。后续需要设计相应协议。 - **功能参数描述。** 当前功能的专用输入必须手工声明;SDK 无法从任意 Cordis 插件配置或 NPM package.json 信息中自动推导可交互参数。后续可以定义有限的声明式参数元数据,但不把任意 Cordis 插件配置转换成通用表单。 - **SDK 应用级配置。** 当前项目资源模型只描述 Cordis 配置项及单个 Cordis 插件拥有的配置键,因此所有受 SDK 管理的配置都必须归属某个插件。跨插件或面向整个 SDK 应用的设置没有独立持久化位置;后续需要定义应用级配置文档及其所有权、读取和修改边界。 @@ -107,23 +107,23 @@ create-sdk 保留隐藏的 `--link-workspace` 选项供 Harness 仓库开发和 **保留静态 Catalog 与中心 engine。** 该方案改动最小,但功能参数、round-trip、独占文件和 create/config 复用都会继续进入同一个协调中心;拆文件只能缩短单文件,不能收拢职责。 -**使用 `wizard.json` 与通用 Questionnaire。** 静态表单无法直接表达功能依赖、选项切换、已有值回填和项目资源变化;类型、gate 和动态 option 最终仍要通过字符串 registry 与过程式 `run()` 连接,形成新的内部 DSL。 +**使用 `wizard.json` 与通用 Questionnaire。** 静态表单无法直接表达功能依赖、选项切换、已有值回填和项目资源变化;类型、门禁和动态选项最终仍要通过字符串注册表与过程式 `run()` 连接,形成新的内部 DSL。 -**公开本地链接 flag。** 该模式依赖 Harness monorepo 布局和未发布 package,只服务仓库开发;公开后会形成无法对外兑现的项目创建合同,因此保持隐藏。 +**公开本地链接 flag。** 该模式依赖 Harness monorepo 布局和未发布包,只服务仓库开发;公开后会形成无法对外兑现的项目创建契约,因此保持隐藏。 ## 验收标准 -- create 与 config 只通过 `SdkProject` 和 `ProjectEditSession` 修改工程,写入前的任何业务、文件或并发校验失败都不产生磁盘变化 -- 新增普通功能、功能选项或参数只扩展类型化 spec 或所属行为对象,create/config 流程不增加中央 switch +- create 与 config 只通过 `SdkProject` 和 `ProjectEditSession` 修改工程,写入前的任何业务、文档或并发校验失败都不产生磁盘变化 +- 新增普通功能、功能选项或参数只扩展类型化 spec 或所属行为对象,create/config 工作流不增加中央 switch - 功能模型、NPM 依赖与其他资源配置、不一致检测由 helper 统一实现 -- 结构化文件通过 `*File` 文档对象修改;一次性文件和完整产品文案通过所属 package 的 Handlebars 模板生成,业务决策不进入模板 DSL -- `dsh-sdk start/dev/build/config` 是运行产品面,typecheck 直接使用 `tsc -b`,HMR 不通过命令隐式注入,`node-addon-require-builtin` 只由 scripts package 传递提供 +- 结构化文件通过 `*File` 文档对象修改;一次性文件和完整产品文案通过所属包的 Handlebars 模板生成,业务决策不进入模板 DSL +- `dsh-sdk start/dev/build/config` 是运行时产品接口,类型检查直接使用 `tsc -b`,HMR 不通过命令隐式注入,`node-addon-require-builtin` 只由 scripts 包传递提供 - `--link-workspace` 只作为隐藏的仓库开发选项存在,并对 npm、pnpm 和 Yarn 保持单一模块身份 ## 风险 -- 行为对象与类型化 spec 并存会形成两种扩展形状;专用类必须只用于确实依赖项目上下文或自定义的功能,否则会重新产生无意义的类型层次 +- 行为对象与类型化 spec 并存会形成两种扩展形状;专用类必须只用于确实依赖项目上下文或需要自定义行为的功能,否则会重新产生无意义的类型层次 - 乐观并发检查与写前校验不能解决写入中途的 I/O 故障,调用方仍需向开发者报告可能的部分提交 -- 隐藏链接模式依赖仓库目录与 package manager 链接语义,仓库布局或工具行为变化时必须与实现一起更新 -- Cordis loader 从自身模块路径加载 `node-addon-require-builtin`;npm、pnpm 或 Yarn 的 NPM 依赖布局变化时,scripts package 必须继续满足该可选对等依赖(optional peer dependency) +- 隐藏链接模式依赖仓库目录与包管理器链接语义,仓库布局或工具行为变化时必须与实现一起更新 +- Cordis loader 从自身模块路径加载 `node-addon-require-builtin`;在 npm、pnpm 和 Yarn 的 NPM 依赖布局下,scripts 包必须持续满足该可选对等依赖(peer dependency) - Handlebars 的 `noEscape` 把目标语言编码责任交给 typed model 构造方;新增模板字段时必须在 owner 处完成正确转义,下游 Handlebars 占位符必须在模板源码中显式转义 diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml index bbb2b78989..e0c0fa0ccd 100644 --- a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md 2026-07-19-required-cancellation-through-tool-capability-seams.md: c2cfb09f27222136965058695e9b6b706ac688a9 -2026-07-19-required-cancellation-through-tool-capability-seams.zh.md: f7a1d303212dfab6da27feba2d6e7195ea07bd50 +2026-07-19-required-cancellation-through-tool-capability-seams.zh.md: 6aa7340d45e99b9072c28c6a1cfe6a769ff8f795 diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md index f7a1d30321..6aa7340d45 100644 --- a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 工具可达能力接缝中的必填取消 +# Agent Note: 工具可达能力 seam 中的必填取消 Status: proposed @@ -8,17 +8,17 @@ Status: proposed 已经实现的[工具注册表取消契约](../../implemented/architecture/2026-07-19-cooperative-tool-cancellation.md)让每个工具主体中的 `exec.signal` 成为必填值,但许多由工具主体调用的异步能力接口仍接受可选信号。因此,工具可以满足自身类型,却在下一次同进程调用时意外丢失取消。 -这项缺口会沿调用链传递。文件系统工具可能调用路径解析和 I/O,Web 工具可能调用提供方,Bash 工具可能调用执行器,组合工具可能启动或等待任务、subagent 或工作流。只要某个控制工具所持有工作的等待操作允许省略信号,TypeScript 就无法证明取消仍能到达拥有副作用的边界。 +这项缺口会沿调用链传递。文件系统工具可能调用路径解析和 I/O,Web 工具可能调用提供方,Bash 工具可能调用执行器,组合工具可能启动或等待任务、subagent 或工作流。只要某个控制工具所持有工作的、且会被工具等待的操作允许省略信号,TypeScript 就无法证明取消仍能到达拥有副作用的边界。 -要求仓库中所有异步函数都携带信号会过度扩张。有些操作无法从工具到达,有些同步查询不会等待或持有持续工作,而明确分离的工作在刻意交接后已经拥有新的所有者。 +要求仓库中所有异步函数都携带信号会过度扩张。有些操作无法从工具到达,有些同步查询无法等待或持有持续工作,而明确分离的工作在刻意交接后已经拥有新的所有者。 ## 提议 -所有能从工具主体到达、且在工具仍持有或等待该操作期间执行的异步同进程能力操作,都必须接收 `AbortSignal`。根据所属接缝的既有形态,这项要求可以表现为位置参数,也可以表现为必填的只读请求字段,但省略信号必须导致 TypeScript 编译失败。 +所有能从工具主体到达、且在工具仍持有或等待该操作期间执行的异步同进程能力操作,都必须接收 `AbortSignal`。根据所属 seam 的既有形态,这项要求可以表现为位置参数,也可以表现为必填的只读请求字段,但省略信号必须导致 TypeScript 编译失败。 每个直接调用方提供自己持有的信号,或从自身必填的操作上下文继续传递信号。实现可以派生子截止时间或取消作用域,但派生信号在委托期间仍须与上游信号关联。能力实现不得生成永不中止信号、使用环境式异步本地取消,也不得仅为重复类型化同进程契约而在运行时校验 `AbortSignal`。 -迁移首先从每个第一方 `ToolDefinition.execute()` 出发,清点其等待的能力调用;随后把每个内聚的接口、实现和使用方接缝连同测试与生成的 API 文档一起修改。文件系统、Bash 与任务、Web 与提供方、工作流与 subagent、代码运行时等能力族可以通过独立 PR 迁移,以保持每项变更可审查;但根据仓库的预发布原则,已经迁移的接口不得保留可选兼容重载。 +迁移首先从每个第一方 `ToolDefinition.execute()` 出发,清点其等待的能力调用;随后按内聚的接口/实现/消费方 seam,将测试与生成的 API 文档一并修改。文件系统、Bash 与任务、Web 与提供方、工作流与 subagent、代码运行时等能力族可以通过独立 PR(Pull Request)迁移,以保持每项变更可审查;但根据仓库的预发布原则,已经迁移的接口不得保留可选兼容重载。 ### 范围边界 @@ -26,7 +26,7 @@ Status: proposed 本提议不包含同步注册表查询、可用性检查、schema 渲染、参数分类,以及其他无法保留异步工作的操作。明确交接所有权后的分离工作也不在范围内:任务、工作流、worker 或 subagent 成功发布给新的生命周期所有者后,其分离生命周期由新所有者的控制器管理。发起启动的操作在交接提交前仍须接收调用方信号;之后若另一次工具调用等待该分离工作,则必须使用该次调用自己的信号。 -若外部协议本身允许省略取消,解析器、配置、模型与工具 JSON、持久化与文件格式、worker、进程或线协议输入仍可保留可选取消。所属边界必须先把该输入解析为必填的同进程信号,再调用已经迁移的能力接缝。 +若外部协议本身允许省略取消,解析器、配置、模型与工具 JSON、持久化与文件格式、worker、进程或协议输入仍可保留可选取消。所属边界必须先把该输入解析为必填的同进程信号,再调用已经迁移的能力 seam。 ## 考虑过的替代方案 @@ -34,19 +34,19 @@ Status: proposed **通过 lint 规则或回调检查强制传递。** 不予采纳,因为语法检查无法可靠识别所有权、派生信号、抽象层或正确的完全停稳行为。必填接口参数可以在 TypeScript 能检查每个调用方的位置表达契约。 -**把 `ToolRunContext` 传入所有能力。** 不予采纳,因为能力需要的是取消,而不是工具身份、agent 状态或上下文延后功能。传递更大的上下文会让可复用服务耦合到工具注册表,也会掩盖狭窄接缝。 +**把 `ToolRunContext` 传入所有能力。** 不予采纳,因为能力需要的是取消,而不是工具身份、agent(智能体)状态或上下文延后功能。传递更大的上下文会让可复用服务耦合到工具注册表,也会掩盖狭窄的 seam。 **使用环境式异步本地信号。** 不予采纳,因为隐藏传递会让所有权和分离交接难以审计,使测试复杂化,并可能让调用静默绑定到错误的生命周期。 **在能力实现中加入默认或永不中止信号。** 不予采纳,因为默认值会抹去缺失的所有者,而不是在编译期暴露问题。 -**在已经实现的工具注册表变更中迁移所有能力。** 不予采纳,因为传递性的接口修改横跨独立能力族。单独保留这项提议既能维持已实现的注册表决策,也能让每个深层接缝通过聚焦测试完成迁移。 +**在已经实现的工具注册表变更中迁移所有能力。** 不予采纳,因为传递性的接口修改横跨独立能力族。单独保留这项提议既能维持已实现的注册表决策,也能让每个深层 seam 通过聚焦测试完成迁移。 ## 验收标准 - 清单把每个第一方工具主体映射到所有权交接前可以到达的异步能力操作。 - 每个范围内的能力接口都要求 `AbortSignal`,并由编译期契约测试证明省略信号会失败。 -- 接口、实现、直接使用方、测试辅助函数、示例和生成的 API 引用必须一起迁移,不保留兼容重载或生产环境永不中止哨兵。 +- 接口、实现、直接消费方、测试辅助函数、示例和生成的 API 引用必须一起迁移,不保留兼容重载或生产环境永不中止哨兵。 - 派生截止时间和包装层作用域仍与调用方信号关联,集成测试证明取消到达副作用所有者,且等待的工作完全停稳。 - 同步查询和明确交接后的分离工作不受这项要求约束;存在歧义时,需要记录并测试所有权转换。 - 只有真实的无类型边界才添加运行时校验,不得重复校验 TypeScript 已要求的字段或参数。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 6e5f8b8391..176097ad5e 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.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/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md 2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d -2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee +2026-07-24-domain-kv-storage-and-workspace.zh.md: cb1fb1346a28b0c3249848705ca209f57cab766e diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 050f72cd33..cb1fb1346a 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -1,29 +1,29 @@ -# Agent Note: Domain KV storage capability seam and the workspace entity +# Agent Note: 领域 KV 存储能力 seam 与 workspace 实体 Status: proposed [English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文 -## Problem +## 问题 -host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求: +host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:仅追加、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求: - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。 -- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 +- **session 动态元信息**(可预见的第二个消费方)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 另外,Session 删除需要 `SessionPersistence` 删除原语和 `session.delete` 端点。该空白的设计随本 Note 定案,但实现仍属未来工作。 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。 -## Proposal +## 方案 -新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面)、两个后端、domain 领域数据形式——及 workspace 消费者包;给 `SessionPersistence` 扩删除原语。 +新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面)、两个后端、domain 领域数据形式——及 workspace 消费方包;给 `SessionPersistence` 扩删除原语。 | 包 | 路径 | ctx 面 | 本期 | | --- | --- | --- | --- | | `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage`(枢纽) | ✓ | -| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册 backend `json` | ✓ | -| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册 backend `sqlite` | ✓ | +| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册后端 `json` | ✓ | +| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册后端 `sqlite` | ✓ | | `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | 挂载 `ctx.storage.domain` | ✓ | | `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ | | `SessionPersistence.delete` 扩面 + 级联删编排 | `packages/session-persistence/*` | 既有 seam 新方法 | ✗ future work(本期不动 session 侧) | @@ -35,13 +35,13 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis ### `dsh-storage`:存储枢纽 -纯注册枢纽,自身不做 IO,无 Config。`Storage` service 挂 `ctx.storage`,两个面:`backend`(`BackendRegistry`:`register(name, backend)` 返回 disposer、重名 throw;`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map,`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts` 与 `src/registry.ts`。 +纯注册枢纽,自身不做 IO,无 Config。`Storage` 服务挂 `ctx.storage`,两个面:`backend`(`BackendRegistry`:`register(name, backend)` 返回 disposer、重名 throw;`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map,`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts` 与 `src/registry.ts`。 **多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置(见下),不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。 一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud。`kv` facet 的原语面:`open(descriptor)`(descriptor = 名字/版本/表名清单/有无 global,名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unit,unit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op)/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文(含逐方法 JSDoc)在 `packages/storage/storage/src/backend.ts`。 -backend 契约(共享契约测试逐条断言,两后端同套件): +后端契约(共享契约测试逐条断言,两后端同套件): 1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。 2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。 @@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" ( ### `dsh-domain`:领域数据形式 -单实现不抽象;消费者只依赖这层,不直接触后端。 +单实现不抽象;消费方只依赖这层,不直接触后端。 ```ts ignore-check export const Config = z.object({ @@ -108,7 +108,7 @@ export function apply(ctx: Context, config: Config) { (facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。) -域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config): +域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的唯一真源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config): ```ts ignore-check export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G } @@ -156,7 +156,7 @@ export interface KvTable<K extends string, V> { 规则: - **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。 -- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。 +- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费方包。 - **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。 - **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。 - **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。 @@ -242,7 +242,7 @@ export class WorkspaceRegistry extends Service { - **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。 - **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。 -- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 +- 消费方只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(注册表缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 - **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保留 Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力。 一致性口径(账 = 归属唯一依据;实现与测试基准): @@ -256,7 +256,7 @@ export class WorkspaceRegistry extends Service { ### 复用与 session 后端迁移展望 -**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是 append-only 流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。 +**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是仅追加流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。 现状复用审计(迁移前就能看清的账): @@ -266,7 +266,7 @@ export class WorkspaceRegistry extends Service { | JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet | | SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 | | SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet | -| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 | +| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,在 domain 层对应的是写串行链,各归各 | | encodeSegment(id 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet(一 session 一文件)迁移时随之下沉 | **本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。 @@ -275,8 +275,8 @@ export class WorkspaceRegistry extends Service { | 套件 | 覆盖 | 后端 | | --- | --- | --- | -| backend 契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite(`:memory:` + 临时目录) | -| registry/mount | 重复注册、未挂载访问、disposer 摘除 | — | +| 后端契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite(`:memory:` + 临时目录) | +| 注册表/mount | 重复注册、未挂载访问、disposer 摘除 | — | | domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一(json) | | workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json | | session delete 契约(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite | @@ -292,40 +292,40 @@ export class WorkspaceRegistry extends Service { | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | | 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 | -| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite,数据手工导一次 | 路由即配置,消费者零改动 | -| 多段 key | 两段 key 消费者出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key | +| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite,数据手工导一次 | 路由即配置,消费方零改动 | +| 多段 key | 两段 key 消费方出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key | | scope 维度 | "每 workspace 一份"的域出现且复合 key 表达不动 | DomainSpec 加 scope + 文件名 scope 段(encodeSegment) | 名字字符集已收紧,文件名不冲突 | | 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — | | 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 | | session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — | | Session 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 | -## Alternatives considered +## 备选方案 -- **复用 session-persistence 的 coordinator/后端**:事件日志语义(append-only、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。 -- **workspace 专用存储包,后续再抽 seam**:第二个消费者(session sidecar)已可预见,届时泛化要再动一次接口。 +- **复用 session-persistence 的 coordinator/后端**:事件日志语义(仅追加、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。 +- **workspace 专用存储包,后续再抽 seam**:第二个消费方(session sidecar)已可预见,届时泛化要再动一次接口。 - **domain 与 storage 合为一层**:后端会被迫接触 schema 校验、变更事件、写串行等领域关切;拆开后 storage 后端只做不透明原语(可替换面最小),domain 单实现收敛全部领域逻辑(zod/事件/串行化只写一遍,不随后端翻倍)。 -- **整库单后端二选一(学 session-persistence 单坑位模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单坑位会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步,fail-loud 兜底。 -- **JSON 后端 jsonl 追加 + 墓碑 + 压实**:temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。 +- **整库单后端二选一(学 session-persistence 单 slot 模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单 slot 会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步,fail-loud 兜底。 +- **JSON 后端 jsonl 追加 + 墓碑 + 压实(compaction)**:temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。 - **JSON 一表一文件**:覆写下文件粒度不影响写成本,按域合并文件更少,global 单例有落点。 - **SQLite 整域存单行 blob**:任何一条记录变更都重写整域,失去按 key 精确更新——SQLite 相对 JSON 的唯一优势归零。 - **SQLite 按 schema 生成 typed columns**:DDL 生成器过度建设;document-per-row 足够,查询需求出现再议。 - **每域独立 sqlite db 文件**:与仓库一库多表惯例相反。 - **path 作为 workspace key**:规范化/符号链接解析会改写 path;引用锚点必须稳定。 - **归属用 cwd 派生(或与账合并)**:双真相源;cwd 表达不了排序;归属本就是 workspace 侧事实。 -- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费者);需要 diff 的消费者自己持有上次快照。 +- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费方);需要 diff 的消费方自己持有上次快照。 - **删除自动 cancel 运行中 session**:持久层/编排层反向牵动运行时,层次变脏;cancel 机制已存在,调用方组合即可。 -## Acceptance criteria +## 验收标准 -- 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 +- 测试矩阵本期四套件全绿:后端契约共享套件在 json/sqlite 双端、注册表/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 - `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。 - session-persistence 包零 diff(本期不动 session 侧的验收线)。 - 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。 -## Risks +## 风险 - **仓库持久化面第一个推式变更事件**(session-persistence 靠 revision 轮询):形态虽有 `goal/changed` 范本,但"存储层发事件"是新先例,下期 RPC 消费时才能验证形态是否合适。 -- **JSON 后端整域覆写的规模前提**:若第二个消费者(session sidecar)在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。 -- **删除语义的编排层检查依赖 `ctx.sessions` 弱依赖**:headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session);多进程本就在不做清单内,接受。 +- **JSON 后端整域覆写的规模前提**:若第二个消费方(session sidecar)在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。 +- **删除编排对 `ctx.sessions` 的弱依赖**:headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session);多进程本就在不做清单内,接受。 - **facet 泛化以未来的 `log` facet 为设计依据但本期不实现它**:存在"预留形状不合身"的风险;缓解是本期后端介质代码按复用审计表的下沉形状组织,`log` facet 真正落地时只动 facet 层。 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 96dc47f9f7..1e375b34d3 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/proposed/architecture/2026-07-25-client-settings-locale-theme.md -2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428 -2026-07-25-client-settings-locale-theme.zh.md: 05edbb3c550828832a390e3cf4fad3262b5be196 +2026-07-25-client-settings-locale-theme.md: fdcdcff92fa324803d5f2343f3fc3d9dcca069c7 +2026-07-25-client-settings-locale-theme.zh.md: b1536a1515fa7022320934dfdabf94c815e8ba04 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index c86d6ac053..fdcdcff92f 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -55,11 +55,11 @@ root └─ models (order 10) ui-models 注册 ``` -Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. +Section and item contributions use `ctx.slots.inject()` and do not depend on the client manifest's apply order; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md). The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. -### Future work: promote slot declarations to first-class injectable waits +### Slot declarations are first-class injectable waits -`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:<name>` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:<name>'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form. +`SlotsService.inject()` now waits on the typed ledger key directly; it does not bridge declarations into synthetic `slot:<name>` Cordis services. The callback follows declaration collapse and redeclaration while its controller remains owned by the contributing plugin fiber, and direct registration into an undeclared slot still fails loud. This removes the stale-disposer presence machine and the typo-prone parallel service namespace. The complete lifecycle and failure contract lives in the [slot declaration injection decision](../../implemented/architecture/2026-08-05-slot-declaration-injection.md). ### Service contracts @@ -127,4 +127,4 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on ## Risks -The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. +The apply order of slot declarations and contributions is not fixed, so every section/item registrant must use `ctx.slots.inject()` rather than a service or local-disposer presence signal. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 05edbb3c55..b1536a1515 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -4,40 +4,40 @@ Status: proposed [English](2026-07-25-client-settings-locale-theme.md) | 中文 -## Problem +## 问题 -浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 +浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题注册表同时承担状态与呈现职责。 -## Proposal +## 提案 -**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。 +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明 slot、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应 slot 注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。 -Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 +Sidebar 声明 `sidebar.settings` single slot,`ui-settings` 占用它并声明四个 slot:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名称全部解析自 slot 内容:trigger 的无障碍名称即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list slot,功能插件的偏好行按 order 排入。 Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 -`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 +`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个服务都以 getter 读取、setter 写入并用 typed Cordis 变更事件发布 immutable snapshot;服务自己持久化偏好(只存 id,坏值回退默认)。 -功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 +功能行的 apply 层各自订阅自家变更事件(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或服务。 Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 -Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 +Theme 服务不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 ### 首期注册面 | 注册面 | 属主插件 | 首期内容 | |---|---|---| | chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 | -| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` slot 声明 | | Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | | Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | | Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | -| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | +| 插件 | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | -首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 +首期只对 Settings 浮层内文案进行本地化;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 -### Slot topology +### slot 拓扑 ```text root @@ -55,13 +55,13 @@ root └─ models (order 10) ui-models 注册 ``` -section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、一键 dispose;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 +section/item contribution 使用 `ctx.slots.inject()`,不依赖 client manifest 的 apply 顺序;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 -### Future work:坑位声明升格为可 inject 的一等等待物 +### slot 声明是一等可注入等待对象 -`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:<name>` 服务(value 为坑位 spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:<name>'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。 +`SlotsService.inject()` 直接等待有类型约束的 ledger key;它不会将声明桥接为合成的 `slot:<name>` Cordis 服务。回调会跟随声明折叠与重新声明,而其控制器仍归贡献方插件 fiber 所有;直接向未声明 slot 注册仍会大声失败。这删除了陈旧 disposer 判在位机器和容易因拼写错误出错的平行服务命名空间。完整的生命周期与失败契约见 [slot 声明注入决策](../../implemented/architecture/2026-08-05-slot-declaration-injection.md)。 -### Service contracts +### 服务契约 ```ts export type ThemePreference = 'light' | 'dark' | 'system' @@ -100,31 +100,31 @@ export interface Events { Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。 -## Alternatives considered +## 曾考虑的替代方案 -**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。 +**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费方;全树刷新放大影响面,也把业务偏好接入 shell。 -**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 +**Theme 服务直接修改 DOM。**注册表服务因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 -**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。 +**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费方拿到一致的已解析 snapshot。 -**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 +**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占用 slot」的组合模型。 **按功能为每个 section 单开 `ui-settings-*` 卫星包。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且卫星包反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:preference 行随功能包交付;`ui-settings-general` 只收无主文案(chrome 与 General 骨架),不承载任何功能的设置面。 -**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 +**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个服务自造 React 钩子也绕开 slot store 的统一绑定。 -## Acceptance criteria +## 验收标准 - Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。 - 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。 -- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 -- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。 -- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 +- Locale 与 Theme 的写入只走 setter,持续同步只走变更事件。 +- 功能行 store 初始化走 getter,后续由自家变更事件更新并局部重渲染。 +- Layout 独立应用 Theme snapshot,Theme 服务不访问 DOM;presenter 不出现 system 分支。 - 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 - Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 - 浮层经 close 按钮、遮罩点击、ESC 均可关闭。 -## Risks +## 风险 -slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 +slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须使用 `ctx.slots.inject()`,而不能以服务或本地 disposer 作为在位信号。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 8f720e33b5..5fabe8a942 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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/proposed/architecture/2026-07-27-session-projection-and-command-log.md 2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 -2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db +2026-07-27-session-projection-and-command-log.zh.md: a22ebe57811339a0e583ae00909e60482ddb57b1 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 500f07968d..a22ebe5781 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Session projections and command lifecycle logging +# Agent Note: 会话投影与命令生命周期日志记录 Status: proposed [English](2026-07-27-session-projection-and-command-log.md) | 中文 -## Problem +## 问题 三个在途的 web 功能——todo(#497)、goal(#527)、plan mode(#587)——都要从会话日志推导按会话的状态并呈现到浏览器客户端,而三者各自发明了一套同样的机制: @@ -14,7 +14,7 @@ Status: proposed 底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态;host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。 -## Proposal +## 提案 先立四件基础设施,之后各领域都退化为纯贡献方。 @@ -24,7 +24,7 @@ Status: proposed ### host 侧投影注册表(`dsh-session-projection`,新包) -一个轻量的接口包(package):merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。 +一个轻量的接口包:merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。 领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责数学本身。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本契约中没有任何特殊地位。 @@ -50,7 +50,7 @@ declare module 'cordis' { ``` - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。 -- **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 +- **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 - **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 @@ -125,17 +125,17 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` 客户端 flow 构建器新增一个通用命令节点(run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`,key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run` 的结构化字段与自己的投影值(`useProjection`)——与 toolview 解散之后的工具行同一形状。 -## Delivery plan +## 交付计划 基础设施先行;三个在途 PR(Pull Request)原样不动,待基座落地后重新对接(它们的迁移映射即指南): -1. **host 基座**:`dsh-session-projection`(单元契约、正向驱动、水位线缓存)+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入(此时块与帧直接缺席)。 +1. **host 基座**:`dsh-session-projection`(单元契约、主动驱动、水位线缓存)+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入(此时块与帧直接缺席)。 2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。 3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。 4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。 5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。 -## Alternatives considered +## 备选方案 **专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq,以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。 @@ -147,7 +147,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` **客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——曾是第二稿,后被否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。 -**对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西需要它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 +**对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 **`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。 @@ -163,7 +163,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` **让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。 -## Acceptance criteria +## 验收标准 - 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。 - 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。 @@ -172,12 +172,12 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` - `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 契约(包括 `useSelection`)。 - 会话标题搭乘这对通用机制(基线块 + 投影帧);专设的 `session/title` 帧与客户端标题快照表彻底移除。 -## Risks +## 风险 - **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元契约让完整状态在每次转移处都是显式的。 - **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。 - **注册表的实时增删不做推送**:会话中途加载或卸载领域插件会改变键集,但不会触发任何会话事件、也不会推任何帧;开着的客户端持有陈旧的 key 直到下次尾页拉取(重连、缺口修补、打开)。接受为仅开发期(HMR)的陈旧时窗——日后可以在变更流上加一个注册表变更推送,契约不受影响。 -- **忙碌会话上的正向驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,契约不变。 +- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,契约不变。 - **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。 - **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。 - **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价;设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index 0c5c704684..f1bd359e19 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md 2026-07-28-storage-root-and-derived-medium-recovery.md: 2edb60d204da736bf14ace6278fa4ee7b9e4b8f7 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: daef4f7661df069df3438f909f2744880a93a873 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 802ca16f399446777203581d246444e53d632a34 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index daef4f7661..802ca16f39 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -1,10 +1,10 @@ -# Agent Note:存储根目录落点与派生介质恢复 +# Agent Note: 存储根目录落点与派生介质恢复 Status: proposed [English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文 -## Problem +## 问题 持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。 @@ -12,7 +12,7 @@ Status: proposed **现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 -## Proposal +## 提案 两个独立改动,一个缺口一个。 @@ -24,12 +24,12 @@ Status: proposed ### 声明派生介质:损坏时重置而非拒绝 -- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。 +- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的真源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。 - `KvFacet` 增加一个原语:`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。 - `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。 - 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。 -## Alternatives considered +## 备选方案 **保持按启动目录的 `.storages`(改动前现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。 @@ -45,15 +45,15 @@ Status: proposed **所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。 -## Acceptance criteria +## 验收标准 - 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`)——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)。 - `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。 - 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。 - facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。 -## Risks +## 风险 -- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。 -- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。 -- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。 +- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。 +- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费方);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。 +- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。 diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index 23961c33c7..0b1aa5995a 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md 2026-06-30-pre-tool-input-rewrite.md: f35e6af465ce8cec5685911c43e12b8dd66f2e6a -2026-06-30-pre-tool-input-rewrite.zh.md: c94c647bb6867199b72528bc84c58a08ae93e27e +2026-06-30-pre-tool-input-rewrite.zh.md: 5793b38293e117e328350722ad1efb3126fd176c diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index c94c647bb6..5793b38293 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -[拦截 seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 +[拦截 seam Agent Note](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 ## 问题本质:执行前参数的三个读取方 @@ -42,7 +42,7 @@ Status: proposed ## 风险 -- 重写 `assistant/message` 中的工具调用块会改变模型「看到自己说了什么」;是否有提供方在回放时拒绝这种改动,是一个需要通过实验确定的开放问题,必须在决策形状冻结之前解决。 +- 重写 `assistant/message` 中的工具调用块会改变模型「看到自己说了什么」;是否有提供方在回放时拒绝这种改动,是一个需要通过实验确定的开放问题,必须在决策结构定型之前解决。 - 更早的重写阶段改变了 `assistant/message`、`tool/call`、钩子审计事件与执行之间的顺序关系;设计必须固定这一顺序,同时不削弱轮次封闭性或调用/结果邻接性。 ## 开放问题 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index 7dcdab8078..e6fe2edcf1 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-06-recallable-compaction.md 2026-07-06-recallable-compaction.md: ed5491e642ea7ac99fd9f4ba071a61e655f968d3 -2026-07-06-recallable-compaction.zh.md: 4060df2c2550f9ea3287adfb51d097c1a60baf71 +2026-07-06-recallable-compaction.zh.md: ad3cba250973a8c8d9545a31b312cd4d3f23725f diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index 4060df2c25..ad3cba2509 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 可回溯压缩(compaction):索引检查点、状态检查点与会话内历史回溯 +# Agent Note: 可回溯压缩:索引检查点、状态检查点与会话内历史回溯 Status: proposed @@ -6,7 +6,7 @@ Status: proposed ## 问题 -压缩是一扇单向门。模型看到的摘要没有指向被其遮蔽内容的引用,因为 `shadowedRange` 来源只存在于仅写入日志、模型不可见的 `compact/summary` 事件上,也没有工具能让模型重新读取被遮蔽的区段。即使仅追加日志仍保存每一个字节,摘要器丢弃的内容也会离开模型可触达的世界。重复压缩会进一步放大问题:每一轮都会重写头部检查点,因此请求前缀每次都会完全失去提示词缓存命中,而更早的摘要也会一代又一代地被重新摘要。 +压缩(compaction)是一扇单向门。模型看到的摘要没有指向被其遮蔽内容的引用,因为 `shadowedRange` 来源只存在于仅写入日志、模型不可见的 `compact/summary` 事件上,也没有工具能让模型重新读取被遮蔽的区段。即使仅追加日志仍保存每一个字节,摘要器丢弃的内容也会离开模型可触达的世界。重复压缩会进一步放大问题:每一轮都会重写头部检查点,因此请求前缀每次都会完全失去提示词缓存命中,而更早的摘要也会一代又一代地被重新摘要。 根本原因是一个产物承担了两个互相冲突的角色。**索引** 需要冻结、按时间排序且成本低廉;模型的**工作记忆** 则需要全局视图、重新确定优先级并且可变。单一摘要无法同时胜任两者。 @@ -24,7 +24,7 @@ Status: proposed - 用一行关键词记录低频字面锚点,例如确切的错误字符串、值和配置键,并按类别分组; - 由代码组装页脚:`[checkpoint c<summarySeq>: shadows conversation span #<start>–#<end>; originals retrievable via history_read]`。指针根据来源组装,绝不由模型编写。 -已经提交的存根永不重写,也绝不再次进入之后的压缩区域。存根调用采用分层输入:固定前导内容与逐字节相同的本轮开始状态检查点(该阶段所有调用共享的前缀);随后是先前所有已提交存根的关键词行,使新条目索引其分片的独特内容,而不是重复整个目录;再加最近一两个已提交存根以维持时间连续性;最后是切片本身。同一轮中的同级存根不作为输入,因为并发阶段禁止这种依赖,而与轮次对齐的边界已经维持局部连续性。状态检查点只作为背景,绝不能成为存根需要总结的材料。完全由回溯内容构成的切片只通过代码生成存根,即只写一行指针,不调用 LLM(大语言模型)。存根调用失败时采用相同降级方式:其切片获得一个仅包含代码指针的存根,本轮继续执行,使状态重写成为一轮中唯一的强制 LLM 依赖。 +已经提交的存根永不重写,也绝不再次进入之后的压缩区域。存根调用采用分层输入:固定前导内容与逐字节相同的本轮开始状态检查点(该阶段所有调用共享的前缀);随后是先前所有已提交存根的关键词行,使新条目索引其分片的独特内容,而不是重复整个目录;再加最近一两个已提交存根以维持时间连续性;最后是切片本身。同一轮中的同级存根不作为输入,因为并发阶段禁止这种依赖,而与轮次对齐的边界已经维持局部连续性。状态检查点只作为背景,绝不能成为存根需要总结的材料。完全由回溯内容构成的切片只通过代码生成存根,即只写一行指针,不调用 LLM(大语言模型)。存根调用失败时采用相同降级方式:其切片获得一个仅由代码生成的指针存根,本轮继续执行,使状态重写成为一轮中唯一的强制 LLM 依赖。 ### 状态检查点 @@ -41,7 +41,7 @@ Status: proposed ### 回溯工具 -新增包(package)`@deepseek-ai/dsh-tool-recall`,它只是 `dsh-session` 与 `dsh-compact` 词汇之上的消费方,注册两个面向模型的工具: +新增包`@deepseek-ai/dsh-tool-recall`,它只是 `dsh-session` 与 `dsh-compact` 词汇之上的消费方,注册两个面向模型的工具: - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 @@ -60,7 +60,7 @@ Status: proposed - **工具结果裁剪**(进行中的裁剪服务):其替换节点携带 `sourceEventSeqs`;同一注册表折叠会把经过裁剪的结果列为可回溯。它属于后续范围,两项工作互不阻塞。 - **提供方 token 用量核算**(正在把压缩压力迁移至提供方报告用量的工作):为保护逻辑提供核算基础;本实现堆叠在它之后。 -- **「查询会话」backlog(待翻清单)条目**:它是跨会话的泛化方案;本 Agent Note(agent 决策记录)把范围限定在实时会话内,并选择工具名称与渲染方式,使该工作能够扩展本设计而不产生冲突。 +- **「查询会话」backlog(待办清单)条目**:它是跨会话的泛化方案;本 Agent Note 把范围限定在实时会话内,并选择工具名称与渲染方式,使该工作能够扩展本设计而不产生冲突。 - **训练**:何时回溯属于学习到的行为。确定性页脚与关键词锚点为训练提供稳定目标,而回溯使用情况在会话日志中完全可见,可供轨迹导出;基准测试与 RL 设计由后训练侧推进。 ### 后续事项 @@ -72,19 +72,19 @@ Status: proposed - 定期使用分片原文刷新状态:触发条件是交接探针观察到漂移。 - `stateFallbackThreshold`(存根数量低于阈值时使用完整细节的状态提示词):触发条件是短会话回归。 - 延迟注册回溯工具:触发条件是在从不进行压缩的会话中测得上下文开销。 -- 在 pre-step 分摊存根起草工作:一旦已经陈旧但尚未压缩的内容积累超过 `chunkTokens`,就在下一个 pre-step 起草该分片的存根(一个仅写入日志的草稿事件,在分片周围的上下文仍然存活时写入),让压缩轮提交草稿,而不是集中执行摘要。这是后台压缩的确定性、精确回放等价形式(Claude Code 会话记忆采用这种模式;OpenClaw 证明同步语义完全相同)。触发条件是观察到一轮延迟,或近实时起草带来的存根质量收益得到验证。 +- 在 pre-step 分摊存根起草工作:一旦已经陈旧但尚未压缩的内容积累超过 `chunkTokens`,就在下一个 pre-step 起草该分片的存根(一个仅写入日志的草稿事件,在分片周围的上下文仍然存活时写入),让压缩轮提交草稿,而不是集中执行摘要。这是后台压缩的确定性、精确回放等价形式(Claude Code 会话记忆采用这种模式;OpenClaw 证明同步语义完全相同)。触发条件是观察到压缩轮次的执行延迟,或近实时起草带来的存根质量收益得到验证。 - 拆分摘要模型;由模型选择分片边界;跨会话回溯;语义搜索回退:每项都必须由各自证据支持。 - 更丰富的 `history_search` 查询形式:正则表达式,以及对日志 JSON 工具结果执行的结构化查询(sql/jq 风格,或由 agent 针对索引存储编写查询)。触发条件是观察到搜索漏检;首版先交付字面量匹配,使回溯路径保持为日志的纯函数。 ## 考虑过的替代方案 -- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR 方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。 +- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR(Pull Request)方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。 - **只保留冻结的全尺寸摘要,不设状态检查点**:不予采纳,因为永久前缀会无界增长、自我加速并最终发生颠簸,而且没有任何内容可以重新确定优先级。 - **只保留纯存根,不设状态检查点**:不予采纳,因为这假定模型知道自己缺少什么,在面对未知的未知时会失败。 - **由 LLM 老化/整合冻结分片**:不作为常规机制,因为摘要的摘要会丢失信息,并使冻结前缀频繁变化;其保留下来的形式是由代码汇总,且延后实现。 - **把完整前缀作为分片摘要器输入**:不予采纳,因为成本为 O(N²);状态文档以 O(state) 提供相同背景。 - **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱 seam。 -- **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略位于配置之后。 +- **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略由配置控制。 - **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。 - **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。 - **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。 @@ -107,4 +107,4 @@ Status: proposed - **存根目录会占用注意力**:每次请求中包含数十张稳定的索引卡,可能稀释模型关注点;验收标准中的 bench 度量会将其与 `compact-basic` 对比。 - **成本**:每轮摘要输入大约是当前实现的两倍;短会话的成本和质量接近当前水平,而设计收益随会话长度增长。 - **状态漂移与职责分工泄漏** 可以通过交接探针和存根评审观察;对应措施已列为后续事项。 -- **两个后端** 会增加维护接口;seam 契约和共享回溯消费方会约束该成本,bench 对比则用于逐步决定默认实现。 +- **两个后端** 会扩大维护范围;seam 契约和共享回溯消费方会限制这一范围,bench 对比则用于逐步决定默认实现。 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index ecb4e98def..8552e86644 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 14e8dde04d9526aaffc0e58be049e13858362887 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: bd76a8f34d86b704494b56331c47ea89bfc8a0aa diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 14e8dde04d..bd76a8f34d 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent(智能体)的进程外委派) +# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) Status: proposed @@ -6,21 +6,21 @@ Status: proposed ## 问题 -subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 +subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为实现机制相似的同类方案。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 ## 提案 -两个兄弟提供方包(package),作为 ACP 后端的结构变体,另加一次提取: +两个兄弟提供方包,作为 ACP 后端的结构变体,另加一次提取: - `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 -- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 +- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread 及其中的一个轮次,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 - `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 ## 已验证的接口事实(固定版本) -两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 +两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过显式失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 **`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 @@ -28,7 +28,7 @@ subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feat - 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 - 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待轮次。 +- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时明确结算为 `error`,而非等待轮次。 - 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 ## 隔离与凭证 @@ -37,7 +37,7 @@ subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feat ## 权限与审批策略 -每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。 +每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有未被前者处理的请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。 ## StopReason 映射 @@ -49,8 +49,8 @@ Claude Code:`success` → `completed`;`error_max_turns`、`error_during_exec 依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险: -- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 -- **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 +- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、对子进程环境隔离和临时目录删除的断言;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24 ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并使用普通 stream-json 通信,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 +- **有密钥 e2e 测试**:每个后端的真实引擎执行真实文件操作,并通过磁盘状态进行验证,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 - **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。 ## 曾考虑的替代方案 @@ -83,7 +83,7 @@ Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema - `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 - Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。 -- SDK 的 optionalDependencies 每平台约 280MB——已接受,限制在单个后端包内。 +- SDK 的 optionalDependencies 每平台约 280 MB——已接受,限制在单个后端包内。 - SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。 -- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制以大声的 spawn/协议 `error` 呈现,而非版本探测。 -- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;连接池、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。 +- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制会明确报出 spawn/协议 `error`,而非版本探测。 +- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;池化、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index b815cfdd2a..6963b28121 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md 2026-07-08-interactive-side-sessions.md: dfd325babe215782c9c1cbec3fd9f874783af7ab -2026-07-08-interactive-side-sessions.zh.md: 9bc9d5c94fdef893134844551a594acc39a99d3b +2026-07-08-interactive-side-sessions.zh.md: a1441cfe12df933855c7b9fe08c5e09f9e6107d3 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index 9bc9d5c94f..a1441cfe12 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -14,8 +14,8 @@ Status: proposed - **Fork 并绑定:** 以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或会话存储方法。 - **顾问定位:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。 -- **合并回写:** 向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在其日志位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 -- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note(agent 决策记录)仅规定与界面无关的机制。 +- **合并回写:** 向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在日志所记录的位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 +- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。 回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 Agent Note 范围内。一次真实适配器 spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml index b2f1ade046..8e61990639 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md 2026-07-14-sdk-developer-projects.md: 65d2bf66232993222832eb0f2f4f56cfcf7afd16 -2026-07-14-sdk-developer-projects.zh.md: 8435a07d9b2545a8f41a1f96743c9c7e6d4daf3d +2026-07-14-sdk-developer-projects.zh.md: 07bf898a7ef672a21951afaa9038a86878ae2900 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md index 8435a07d9b..07bf898a7e 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md @@ -10,19 +10,19 @@ DeepSeek Harness 通过 Cordis 插件对功能进行组合,但从空目录开 一次性生成器只能降低首次创建成本。若生成结果隐藏在 preset 或不可编辑的 CLI(命令行界面)内部,高级开发者无法调整插件树、修改 Cordis 插件配置或增加项目特有行为;若创建后的工程完全脱离工具管理,开发者又必须重新承担所有 NPM 依赖和 Cordis 插件配置的一致性工作。 -初始创建和后续配置面对同一组内置功能。两条流程各自维护功能列表、功能选项和 NPM 依赖时,新增 Cordis 插件、NPM 包或调整配置会使二者逐渐分叉。工程还需要一条普通的本地插件开发路径,参与开发、构建和启动流程。 +初始创建和后续配置面对同一组内置功能。两条流程各自维护功能列表、功能选项和 NPM 依赖时,新增 Cordis 插件、NPM 包或调整 Cordis 插件配置会使二者逐渐分叉。工程还需要一条普通的本地插件开发路径,参与开发、构建和启动流程。 ## 提案 SDK 创建一个普通、显式且归开发者所有的 TypeScript/Cordis 工程。`cordis.yml` 是唯一的运行时插件树;开发和生产读取同一份文件。工程中的 `package.json`、`cordis.yml`、TypeScript 入口、构建配置和 `plugins/*` 均可直接编辑,SDK 不把它们封装成不可见的 preset。 -开发者产品入口只有 `npm create @deepseek-ai/sdk` 和 `dsh-sdk` 命令。前者负责首次创建,`dsh-sdk config` 在创建后管理 SDK 能识别的内置功能,`dsh-sdk dev`、`dsh-sdk build` 与 `dsh-sdk start` 负责开发、构建和启动;本期不提供 `dsh-sdk create`。create 与 config 使用同一份人工编写的功能定义,因此一项功能的功能选项、NPM 依赖、Cordis 配置项、相关文件和识别规则只有一个来源。功能、功能选项等名词由 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md) 的术语表定义。 +开发者产品入口只有 `npm create @deepseek-ai/sdk` 和 `dsh-sdk` 命令。前者负责首次创建,`dsh-sdk config` 在创建后管理 SDK 能识别的内置功能,`dsh-sdk dev`、`dsh-sdk build` 与 `dsh-sdk start` 负责开发、构建和启动;本期不提供 `dsh-sdk create`。create 与 config 使用同一份人工编写的功能定义,因此一项功能的功能选项、NPM 依赖、Cordis 配置项、相关文件和识别规则只有一个来源。[SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md) 定义了功能、功能选项等术语。 SDK 只为功能选择和有限功能选项提供交互,不尝试把任意 Cordis 插件配置变成通用表单。功能选项所需的少量专用输入由所属功能收集;其余 Cordis 插件配置留在 `cordis.yml` 中,并通过注释指明常用改法,由开发者直接修改。 ## 开发者流程 -首次创建按会影响后续问题集合的顺序收集信息:目标目录与 package 身份、模型提供方与凭据、运行接口、内置功能与功能选项、可选本地插件、包管理器,以及是否安装 NPM 依赖并构建。命令参数已提供的答案不重复询问;本期 create 和 config 都要求交互式 TTY,取消创建时不写入目标目录。 +首次创建按会影响后续问题集合的顺序收集信息:目标目录与包身份、模型提供方与凭据、运行接口、内置功能与功能选项、可选本地插件、包管理器,以及是否安装 NPM 依赖并构建。命令参数已提供的答案不重复询问;本期 create 和 config 都要求交互式 TTY,取消创建时不写入目标目录。 ```sh npm create @deepseek-ai/sdk my-agent @@ -39,13 +39,13 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl ## 创建时支持的功能 -下表是本期 create 面向开发者展示的支持集。`required` 始终存在但仍可切换有限功能选项;`default` 在选择树中预选;`optional` 由开发者主动选择。表格说明产品支持集,运行时注册表是实现的事实源。 +下表是本期 create 面向开发者展示的支持集。`required` 始终存在但仍可切换有限功能选项;`default` 在选择树中预选;`optional` 由开发者主动选择。表格说明产品支持集,运行时注册表是实现的真源。 | 功能 | create 状态 | 功能选项 | 限制与关系 | |---|---|---|---| | `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API key;custom 另收集 base URL,模型名可由 CLI 参数覆盖 | | `app` | required | `tui`(默认)/ `acp` / `embed` | 选择运行接口 | -| `spine` | required | `default` | timer、LLM seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop | +| `spine` | required | `default` | timer、LLM(大语言模型)seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop(智能体循环) | | `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具;sandbox 安装本地沙箱提供方和沙箱 bash 后端 | | `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 | | `hmr` | default | `default` | 加载 `@cordisjs/plugin-hmr`;dev 和 start 都启用,使用插件默认配置 | @@ -55,11 +55,11 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl | `web` | optional | `deepseek`(默认)/ `exa` / `perplexity` / `fetch-only` | 搜索功能选项互斥;Exa/Perplexity 收集各自 API key;建议同时启用 timeout policy | | `subagent` | optional | `spawn`(默认)/ `fork`,可多选 | 本期只提供进程内后端 | | `workflow` | optional | `workerthread` | 要求 subagent 的 `spawn` 功能选项 | -| `compact` | optional | `basic` | 使用 SDK 提供的上下文压缩参数 | +| `compact` | optional | `basic` | 使用 SDK 提供的上下文压缩(context compaction)参数 | | `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 | | `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 | | `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 | -| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;只有 `tui` 可选,因为 ACP 是自动化传输,而 embed 不提供人类交互服务 | +| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;只有 `tui` 可选,因为 ACP(Agent Client Protocol)是自动化传输,而 embed 不提供人类交互服务 | `bash` 的两个功能选项都适用于 ACP、TUI 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: @@ -76,7 +76,7 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl ## 生成工程 -使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 TUI,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: +使用默认答案创建 npm 工程时,提供方为 DeepSeek,运行接口为 TUI,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: ```text my-agent/ @@ -99,7 +99,7 @@ my-agent/ | script | 行为 | |---|---| | `dev` | 运行 `dsh-sdk dev index.ts`,为 TypeScript 和本地 workspace 插件注册开发期解析 | -| `build` | 运行 `dsh-sdk build`,调用工程安装的 tsdown 构建根入口和 `plugins/*` package | +| `build` | 运行 `dsh-sdk build`,调用工程安装的 tsdown 构建根入口和 `plugins/*` 包 | | `typecheck` | 直接运行 `tsc -b` | | `start` | 运行 `dsh-sdk start index.js`,启动已构建入口且不隐式构建 | | `config` | 运行 `dsh-sdk config`,修改当前工程功能树 | @@ -118,39 +118,39 @@ my-agent/ `dsh-sdk config` 可以安装缺失功能、启停已安装功能和切换有限功能选项。required 功能不能取消。改变 NPM 依赖后只运行一次项目包管理器安装;安装失败不回滚已经提交的工程文件。 -SDK 只修改功能明确拥有的 Cordis 配置项、配置键、NPM 依赖、`.env.example` 占位和独占文件。同一功能选项的更新保留 Cordis 配置项中的未知配置键;手写或第三方插件只支持按稳定 ID 启停。已知功能被手改成不完整、歧义或无法读取的形状时,`dsh-sdk config` 显示诊断并拒绝自动修改,直到开发者手工修复。 +SDK 只修改功能明确拥有的 Cordis 配置项、配置键、NPM 依赖、`.env.example` 占位和自有文件。同一功能选项的更新保留 Cordis 配置项中的未知配置键;手写或第三方插件只支持按稳定 ID 启停。已知功能被手改成不完整、歧义或无法读取的形状时,`dsh-sdk config` 显示诊断并拒绝自动修改,直到开发者手工修复。 一次 config 会话在内存工作区上累计全部修改。Apply 前完成功能关系、资源冲突和文件形状校验,并比较受影响文件与会话打开时的原文;校验失败或检测到外部修改时不写盘。实际写盘开始后不提供跨文件事务回滚。 ## 维护模型 -Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约定自动暴露。一个功能可以组合多个 Cordis 配置项,功能选项可以共享资源,并声明对其他功能或特定功能选项的功能依赖;新增普通功能或功能选项不应要求同时修改 create 和 config 两个命令流程。 +Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约定自动暴露。一个功能可以组合多个 Cordis 配置项,功能选项可以共享资源,并声明对其他功能或特定功能选项的功能依赖;新增普通功能或功能选项无需同时修改 create 和 config 两个命令流程。 ## 后续工作 -- `dsh-sdk add [package-spec]`:统一本地插件创建与外部 Cordis 插件接入;未指定 package 或仓库来源时创建本地 plugin/tool,指定来源时增加 NPM 依赖和 `cordis.yml` 配置项,来源模型为 GitHub 仓库等扩展保留空间 -- 非交互 create/config:本期两个流程都要求 TTY,不提供供自动化调用的完整输入合同 +- `dsh-sdk add [package-spec]`:统一本地插件创建与外部 Cordis 插件接入;未指定包或仓库来源时创建本地插件/工具,指定来源时增加 NPM 依赖和 `cordis.yml` 配置项,来源模型为 GitHub 仓库等扩展保留空间 +- 非交互 create/config:本期两个流程都要求 TTY,不提供供自动化调用的完整输入契约 - 更多功能专用参数输入:本期产品只展示有限功能选项、secret 和少量专用值,不为 Cordis 插件配置提供通用参数界面 ## 曾考虑的替代方案 **不可编辑的 preset 或生成器托管工程。** 该方案可以缩短初次创建路径,但会隐藏真实插件树和构建边界,使高级开发者无法直接组合 Cordis 插件,也让项目行为依赖 CLI 版本而不是检入的工程文件。 -**只提供一次性生成器。** 创建后完全依赖手工维护,会让功能依赖、功能选项切换和多文件更新再次分散;共享 registry 的 config 流程为生成工程保留持续管理机制。 +**只提供一次性生成器。** 创建后完全依赖手工维护,会让功能依赖、功能选项切换和多文件更新再次分散;共享注册表的 config 流程为生成工程保留持续管理机制。 **为开发和生产维护两份 `cordis.yml`。** 两份插件树会使开发成功无法证明生产加载相同功能;dev 只增加 TypeScript 与本地 workspace 解析,运行配置保持唯一。 **为任意 Cordis 插件配置生成通用表单。** Cordis 插件配置包含嵌套结构、表达式和插件特有语义,通用表单会形成第二套不完整 schema。SDK 只管理有限功能选项和专用 secret,复杂配置继续由开发者直接编辑。 -**使用私有协议发现本地插件。** 普通 package manager workspace、根 NPM 依赖、TypeScript references 和 Cordis 配置项已能表达完整关系;额外发现协议会创造只能由 SDK 理解的隐藏状态。 +**使用私有协议发现本地插件。** 普通包管理器 workspace、根 NPM 依赖、TypeScript references 和 Cordis 配置项已能表达完整关系;额外发现协议会创造只能由 SDK 理解的隐藏状态。 -**在现有工程中提供 `dsh-sdk create`。** create 已能生成一种可编辑的本地插件骨架,后续插件可以沿用普通 workspace 和 Cordis 机制手工添加;再提供同构命令会增加第二条脚手架产品面,却不增加新的组合功能。 +**在现有工程中提供 `dsh-sdk create`。** create 已能生成一个可编辑的本地插件骨架,后续插件可以沿用普通 workspace 和 Cordis 机制手工添加;再提供并行命令会增加第二条脚手架产品面,却不增加新的组合功能。 -**把每个新 Cordis 插件自动暴露为 builtin。** package 无法说明多个插件如何组合成一项产品功能,也无法推导互斥关系、功能依赖、secret、接口适用性和安全限制;支持集需要人工策划,自动化只适合检查候选是否完成分类。 +**把每个新 Cordis 插件自动暴露为 builtin。** 包无法说明多个插件如何组合成一项产品功能,也无法推导互斥关系、功能依赖、secret、接口适用性和安全限制;支持集需要人工策划,自动化只适合检查候选是否完成分类。 ## 验收标准 -- `npm create @deepseek-ai/sdk` 按本文顺序收集项目身份、provider、interface、功能、可选本地插件、包管理器和安装选择,并在取消时保持目标路径不存在 +- `npm create @deepseek-ai/sdk` 按本文顺序收集项目身份、提供方、接口、功能、可选本地插件、包管理器和安装选择,并在取消时保持目标路径不存在 - 默认 npm 工程具有本文目录树和 `dev`、`build`、`typecheck`、`start`、`config` scripts,且 dev/start 使用同一份 `cordis.yml` - create 展示本文功能及功能选项;`bash` 的 local/sandbox 二选一且默认 local,sandbox Cordis 配置项保留可编辑的注释配置示例;HMR 默认选中并同时由 dev/start 加载 - create 的 `plugin` 或 `tool` 选择至多生成一个固定名称的本地插件,并原子更新插件文件与根工程关系;本期不提供 `dsh-sdk create` @@ -160,8 +160,8 @@ Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约 ## 风险 -- 开发者可以把 builtin 手改成 registry 无法识别的形状;SDK 选择停止自动化而不是猜测并覆盖配置 +- 开发者可以把 builtin 手改成注册表无法识别的形状;SDK 选择停止自动管理该功能而不是猜测并覆盖配置 - 多文件写入前的校验和外部修改检测不能提供写入阶段的事务回滚;I/O 中途失败可能留下需要人工修复的部分提交 - sandbox 功能选项依赖目标平台存在可用的本地沙箱后端;后端不可用时必须 fail closed,不能退回无沙箱执行 - HMR 在生产启动中也保持文件 watcher 和热重载行为;这是显式插件选择的结果,不是仅限开发环境的隐式服务 -- `.env` 的仅追加策略会保留已经不用的凭据,SDK 不判断这些用户数据何时可以安全删除 +- `.env` 的仅追加策略会保留已经不用的凭据,SDK 不判断这些用户拥有的密钥数据何时可以安全删除 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..fa7ea8e141 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.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/proposed/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: 1c3ccef23bb5cd9bc37237bd69aac2e2c56649a8 +2026-08-01-windows-pwsh-default.zh.md: 3958d21eb8a9d306009b11d6e9806a1654a8958e diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..1c3ccef23b --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,39 @@ +# Agent Note: Windows defaults to pwsh (roadmap) + +Status: proposed + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. + +## Proposal + +Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. + +1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. +2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. + +The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. + +**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. + +## Acceptance criteria + +- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. +- POSIX hosts are byte-for-byte unaffected (same roster, same executor). +- The shipped-composition e2es assert the platform-gated roster on both families. +- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). + +## Risks + +- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. +- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. +- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..3958d21eb8 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Windows 默认改用 pwsh(路线图) + +Status: proposed + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 + +## 提案 + +两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 + +1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 +2. **pwsh GUI 渲染**——Web 表面以 bash 形状的终端呈现渲染 pwsh 调用(带退出状态 pill 的 terminal 卡),即 bash 终端卡片的对应物。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有终端孪生。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍无人认领。 + +各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 + +**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 + +## 验收标准 + +- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 +- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 +- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 +- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除让终端表面无快照可做)。 + +## 风险 + +- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 +- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 +- **渲染约定**——bash 形状的终端孪生已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍是带快照表面的 UI 设计决策,随阶段 1 一起延期。 diff --git a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml index db4a8984c7..37137ceebb 100644 --- a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-06-11-api-extractor-reports.md 2026-06-11-api-extractor-reports.md: 03f512992fe87ea3d0f8d51a1772ce1ec89a5c0d -2026-06-11-api-extractor-reports.zh.md: a8180124c5e5402dce3c28c1bd8c54219d5b68fc +2026-06-11-api-extractor-reports.zh.md: e3b40f29908ebe1096a02dd4ee5f4886bb5a4537 diff --git a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md index a8180124c5..e3b40f2990 100644 --- a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md @@ -4,7 +4,7 @@ Status: proposed [English](2026-06-11-api-extractor-reports.md) | 中文 -> 从最初的「doc-sync(文档同步门禁)与 API 报告」Agent Note(agent 决策记录)中拆出(首次提出于 2026-06-11)。第 1 至第 2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 +> 从最初的「doc-sync(文档同步门禁)与 API 报告」Agent Note 中拆出(首次提出于 2026-06-11)。第 1 至第 2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 ## 问题 @@ -12,11 +12,11 @@ Status: proposed ## 提案 -使用 api-extractor(或 `tsc --emitDeclarationOnly` 加一份规范化的公开接口导出)为每个包(package)生成一份签入仓库的 `etc/<pkg>.api.md`;CI 在重新生成结果与已签入报告不一致时失败。这样,每一次公开 API 变更都会成为评审者(或评审 agent(智能体))必须看到的一行 diff。 +使用 api-extractor(或 `tsc --emitDeclarationOnly` 加一份规范化的公开 API 清单)为每个包生成一份签入仓库的 `etc/<pkg>.api.md`;CI 在重新生成结果与已签入报告不一致时失败。这样,每一次公开 API 变更都会成为评审者(或评审 agent(智能体))必须看到的一行 diff。 ## 曾考虑的替代方案 -**`tsc --emitDeclarationOnly` 加规范化的公开接口导出**:如果 api-extractor 过于笨重,这是更轻量的机制;两者都能满足提案所需的「签入仓库、可 diff」的报告形态。 +**`tsc --emitDeclarationOnly` 加规范化的公开 API 清单**:如果 api-extractor 过于笨重,这是更轻量的机制;两者都能满足提案所需的「签入仓库、可 diff」的报告形态。 ## 验收标准 diff --git a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml index 624b671a37..f301aae614 100644 --- a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-06-11-architectural-conformance.md 2026-06-11-architectural-conformance.md: f7cb0d7397d4e03df225f68417da43b1fec8de62 -2026-06-11-architectural-conformance.zh.md: aa25ef6d2772642885ef268bd548fc6dad40d3cf +2026-06-11-architectural-conformance.zh.md: c82297fcbe02feed4cd04bc60730a3494b7faa4d diff --git a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md index aa25ef6d27..c82297fcbe 100644 --- a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md @@ -6,15 +6,15 @@ Status: proposed ## 问题 -目前有两项架构保证仅存在于行文中:(1)没有任何组件依赖具体的 loop 包(package)([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确遵循分片协议。二者都应由机制强制执行([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 +目前有两项架构保证仅存在于行文中:(1)没有任何组件依赖具体的 agent loop(智能体循环)包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确遵循分片协议。二者都应由机制强制执行([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 ## 提案 **dependency-cruiser** 配合以下规则: -- `packages/*`(除 agent-loop(智能体循环)自身的 tests 和 examples/ 外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 +- `packages/*`(除 agent-loop 自身的测试和 examples/ 外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 - 禁止跨包深层导入(`@deepseek-ai/dsh-*/src/...` 路径)——只允许使用公开入口点。 -- packages/ 内禁止导入循环。 +- packages/ 内禁止出现循环依赖。 - `vendor/*` 禁止从 `packages/*` 导入。 - 分层:dsh-llm 不导入其他 dsh 包;dsh-session 仅导入 dsh-llm;以此类推(packages/README.md 中的依赖表,强制执行)。 diff --git a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml index bf98665195..a989c4f408 100644 --- a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md 2026-06-11-supply-chain-and-vendor-drift.md: a27ae64556dc7366279824f1480e5681b1e86bf1 -2026-06-11-supply-chain-and-vendor-drift.zh.md: 25c27650709faf1a462ce9779ee6f0a909746311 +2026-06-11-supply-chain-and-vendor-drift.zh.md: b52748e4b4c92a161c5b3ff49c079ff909d11c01 diff --git a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md index 25c2765070..b52748e4b4 100644 --- a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md @@ -6,14 +6,14 @@ Status: proposed ## 问题 -vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时仅在*正向*强制执行(vendor 变更 ⇒ manifest 更新),但没有任何机制验证 manifest 的*声明*:即 vendor/ 确实等于上游指定 SHA 的内容加上所记录的修改。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 +vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时仅在*正向*强制执行(vendor 变更 ⇒ manifest 更新),但没有任何机制验证 manifest 的*声明*:即 vendor/ 确实等于上游指定 SHA 的内容加上所记录的修改。此外,少量真正的 NPM 依赖也没有安全公告监控或更新节奏。 ## 提案 -1. **Vendor 漂移检查**(夜间 CI):以 manifest 中记录的 SHA 浅克隆上游仓库,复制对应的包(package)源码,与 `vendor/*/src` 做 diff。除非 diff 与已记录的本地修改一致(每项修改以签入的 patch 文件保存——日志条目从行文描述变为可验证的产物),否则任务失败。 +1. **Vendor 漂移检查**(夜间 CI):以 manifest 中记录的 SHA 浅克隆上游仓库,复制对应的包源码,与 `vendor/*/src` 做 diff。除非 diff 与已记录的本地修改一致(每项修改以签入的 patch 文件保存——日志条目从行文描述变为可验证的产物),否则任务失败。 2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划定期执行,并在涉及 lockfile 变更的 PR(Pull Request)上触发。 3. **许可证清单**:一个脚本断言每个 vendor 包都携带其 LICENSE 文件,且 package.json 的 `license` 字段与 vendor/README.md 中的清单一致(我们混合了 vendor 的 MIT 与自有的 BSD-3)——作为 CI 步骤运行。 -4. **Renovate**(或定时 agent(智能体)任务)以小 PR 的形式提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 包不在其列(它们的更新遵循 manifest 同步流程,理想情况下是半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、以更新后的 manifest 表格开 PR)。 +4. **Renovate**(或定时 agent(智能体)任务)以小 PR 的形式提议 NPM 依赖更新,这些 PR 走完整门禁套件;vendor 包不在其列(它们的更新遵循 manifest 同步流程,理想情况下是半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、以更新后的 manifest 表格开 PR)。 ## 计划 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index 884682f4f9..98087b0c9b 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.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/proposed/process/2026-06-20-discover-package-inventory.md 2026-06-20-discover-package-inventory.md: 7de865e43f87f0e41fad43b3786b9825509e9d50 -2026-06-20-discover-package-inventory.zh.md: 00195981a1f8036121e3895a0ff8ac342f54dff9 +2026-06-20-discover-package-inventory.zh.md: fed3d9dd8740b2dc22434f8af7e1e8c025502f61 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md index 00195981a1..fed3d9dd87 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -6,15 +6,15 @@ Status: proposed ## 问题 -包(package)与门禁清单在 TypeScript project references、包文档、CI 描述和 Knip 覆盖项中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 +包与门禁清单在 TypeScript project references、包文档、CI 描述和 Knip 覆盖项中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 -[包层级结构](../../archived/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)的 project `references`——TypeScript 要求它们是显式数组(没有通配符形式)。 +[包层级结构](../../archived/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)中的项目引用(`references`)——TypeScript 要求它们是显式数组(没有通配符形式)。 当静态列表编码的是策略时,它们是合理的;当它们只是重复 `package.json`、workspace glob 或包层级结构中已有的 manifest 数据或布局事实时,就是不必要的摩擦。 ## 提案 -让剩余的包与门禁清单可被发现。一个唯一的权威来源,即 `packages/<group>/<pkg>` 层级结构加上包 manifest,应当驱动聚合配置的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`--check` 模式在 `hygiene` / `doc-sync`(文档同步门禁)中发现已提交副本陈旧时失败)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令,而非重述第二份列表。 +让剩余的包与门禁清单可被发现。唯一真源,即 `packages/<group>/<pkg>` 层级结构加上包 manifest,应当驱动聚合配置的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`--check` 模式在 `hygiene` / `doc-sync`(文档同步门禁)中发现已提交副本陈旧时失败)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令,而非重述第二份列表。 层级结构不需要编码关于包的所有事实,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外列表。 @@ -22,7 +22,7 @@ Status: proposed ## 验收标准 -- 聚合配置的 project `references` 由层级结构生成(生成器输出它们;`--check` 门禁在提交副本陈旧时报错),而非手工维护。 +- 聚合配置的项目引用(`references`)由层级结构生成(生成器输出它们;`--check` 门禁在提交副本陈旧时报错),而非手工维护。 - 新增一个包时,不需要为任何门禁编辑静态包列表。 - 文档描述真源,而非重复生成的清单。 - CI 调用聚合命令,由这些命令自行管理其子门禁列表。 diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml index d392c8cf22..e23ab9913b 100644 --- a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md 2026-07-13-human-review-skill-maintenance.md: 76391bd110b7a86b194a9340ffcf7cc4602d1d2e -2026-07-13-human-review-skill-maintenance.zh.md: 67d68d07cc7f467a310b64d833a28da6f04bcc8e +2026-07-13-human-review-skill-maintenance.zh.md: e442a39618a53ffdc130c94874a482315943caff diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md index 67d68d07cc..e442a39618 100644 --- a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md @@ -34,17 +34,17 @@ flowchart TD ### 采纳证据 -每条反馈都携带稳定的来源 ID 和有界的变更证据。评审人的 `commit_id` 仍属于该 PR 时(强制推送场景无法确认即排除),工具会选择 committer 时间戳严格早于反馈的最新 PR commit 作为基线,而不是采用评审人点击的 commit,因为后者可能更旧。工具绝不会直接比较该基线与落地 merge:这种 diff 会混入不断前移的目标分支中的无关变更。相反,它会向采纳评审人提供两份 PR 专用 patch 快照。令 `B` 为反馈基线,`T` 为落地 merge 的目标父级,`M` 为落地 merge。反馈时快照是从 `merge-base(B, T)` 至 `B` 的树 diff;最终快照是从 `T` 至 `M` 的树 diff。因此,目标分支专有变更不会出现在任一 PR patch 中,而反馈后加入 PR 的变更只会出现在最终快照中。遭到强制推送的评审、早于全部现存 PR commit 的反馈,以及无法重建目标父级的落地形态,会在任何评审人看到之前确定性地归类为 `unclear`。合并状态、已解决讨论串、作者回复「已修复」或同文件编辑只是上下文,不是采纳证明;PR 作者自己的评论绝不会进入适配器,因为它们不可能构成对作者自身意见的采纳。 +每条反馈都携带稳定的来源 ID 和有界的变更证据。评审人的 `commit_id` 仍属于该 PR 时(强制推送场景无法确认即排除),工具会选择 committer 时间戳严格早于反馈的最新 PR commit 作为基线,而不是采用评审人点击的 commit,因为后者可能更旧。工具绝不会直接比较该基线与落地 merge:这种 diff 会混入不断前移的目标分支中的无关变更。相反,它会向采纳评审人提供两份 PR 专用 patch 快照。令 `B` 为反馈基线,`T` 为落地 merge 的目标父级,`M` 为落地 merge。反馈时快照是从 `merge-base(B, T)` 至 `B` 的树 diff;最终快照是从 `T` 至 `M` 的树 diff。因此,目标分支专有变更不会出现在任一 PR patch 中,而反馈后加入 PR 的变更只会出现在最终快照中。关联 commit 已因强制推送而不再属于该 PR 的评审、早于全部现存 PR commit 的反馈,以及无法重建目标父级的落地形态,会在任何评审人看到之前确定性地归类为 `unclear`。合并状态、已解决讨论串、作者回复「已修复」或同文件编辑只是上下文,不是采纳证明;PR 作者自己的评论绝不会进入适配器,因为它们不可能构成对作者自身意见的采纳。 ### 双评审人分类与起草 两个独立配置的评审适配器,会从来源(`human-authored`、`forwarded-automation` 或 `unclear`)和采纳情况(`adopted`、`rejected` 或 `unclear`)两个维度,对每个符合条件的条目进行分类。只有两个适配器都判定为 `human-authored` 加 `adopted` 的条目才会继续。采纳集合随后会针对当前 skill 接受第二次独立分类:候选项、已经覆盖、实现专用或并非反馈。单个条目即可符合要求,不要求重复出现。意见分歧会得到一次有界的重新评估;如果仍未解决,则继续保留在运行产物中。单个批次的适配器输出如果未通过 schema 或 id 校验,系统会在批次层按不采纳处理:其中的每条反馈都标记为 unclear 并路由到 `excluded`,而不是中止整次运行;有问题的原始输出会保存在该次运行的私有产物中,供调试使用。如果任一适配器在某项操作的任何非空批次中都没有返回有效结果,运行会以非零状态退出并发出失败记录,而不会报告「没有候选项」。 -主适配器只根据结构化的共同指引起草,绝不接收原始评审文本。根据适配器作者的契约,它保持无工具且只读:返回完整的候选文件内容,由工具校验后写入唯一目标。两个适配器随后评审同一份完整 skill diff;阻塞性问题会进入有界修订循环,而且两者必须批准同一版修订。工具会在运行文档和 lint 检查前,以及报告成功前,再次拒绝暂存改动和目标 skill 之外的编辑,因此检查或并发进程无法通过添加其他路径混入。失败时,工具使用尽力而为的比较并交换恢复自身写入,避免覆盖维护者的并发编辑。成功时,它保存一份候选资料包,其中包含源 `origin/master` commit、源 skill blob ID、已评审 diff、完整候选文件、源反馈 ID 与 URL、落地证据范围、适配器判定和检查结果;它绝不提交、推送、打开或合并 PR。 +主适配器只根据结构化的共同指引起草,绝不接收原始评审文本。根据适配器作者的契约,它保持无工具且只读:返回完整的候选文件内容,由工具校验后写入唯一目标。两个适配器随后评审同一份完整 skill diff;阻塞性问题会进入有界修订循环,而且两者必须批准同一版修订。工具会在运行文档和 lint 检查前,以及报告成功前,再次拒绝暂存改动和目标 skill 之外的编辑,因此检查或并发进程无法通过添加其他路径混入。失败时,工具使用尽力而为的比较并交换回滚自身写入,以免覆盖维护者的并发编辑。成功时,它保存一份候选资料包,其中包含源 `origin/master` commit、源 skill blob ID、已评审 diff、完整候选文件、源反馈 ID 与 URL、落地证据范围、适配器判定和检查结果;它绝不提交、推送、打开或合并 PR。 ### 评审适配器协议 -每个私有可执行文件从 stdin 接收有字节上限、带版本的 JSON 请求,并在 stdout 返回有字节上限且符合 schema 的 JSON。两个评审命令解析为逐字节相同的可执行文件时,工具拒绝运行;这是机械性的最低标准,保证主适配器与次适配器由独立提供方或模型驱动,仍是部署运维方的责任。`access` 与 `tools` 字段是适配器作者承担的契约标记,不是 OS 沙箱:评审子进程在清理后的环境中 spawn,其 `cwd` 指向私有运行目录而非仓库根目录;反馈包装在带随机数的 `<untrusted-feedback nonce="…">` 块中,每个提示词都会要求模型把它视为数据;128 位随机数防止不受信任的正文伪造结束标签。每个子进程都采用有界、感知中止的进程树清理。适配器作者把每项操作实现为纯只读推理(inference);即使 `edit` 操作也只会在 JSON 中返回完整候选内容,由工具校验后写入唯一目标。每个生产 `git`/`gh`/检查命令同样在清理后的环境中 spawn,避免 pre-push 钩子的路由变量无提示地重定向维护工具。候选写入与失败回滚会针对最近一次写入内容使用尽力而为的比较并交换;回滚还会取消暂存目标,避免由适配器或检查暂存的候选项在失败运行后遗留并进入之后的 commit。 +每个私有可执行文件从 stdin 接收有字节上限、带版本的 JSON 请求,并在 stdout 返回有字节上限且符合 schema 的 JSON。两个评审命令解析为逐字节相同的可执行文件时,工具拒绝运行;这只是最低限度的机械检查;确保主适配器与次适配器由独立提供方或模型驱动,仍是部署运维方的责任。`access` 与 `tools` 字段是适配器作者承担的契约标记,不是 OS 沙箱:评审子进程在清理后的环境中 spawn,其 `cwd` 指向私有运行目录而非仓库根目录;反馈包装在带随机数的 `<untrusted-feedback nonce="…">` 块中,每个提示词都会要求模型把它视为数据;128 位随机数防止不受信任的正文伪造结束标签。每个子进程都采用有界、感知中止的进程树清理。适配器作者把每项操作实现为纯只读推理(inference);即使 `edit` 操作也只会在 JSON 中返回完整候选内容,由工具校验后写入唯一目标。每个生产 `git`/`gh`/检查命令同样在清理后的环境中 spawn,避免 pre-push 钩子的路由变量无提示地重定向维护工具。候选写入与失败回滚会针对最近一次写入内容使用尽力而为的比较并交换;回滚还会取消暂存目标,避免由适配器或检查暂存的候选项在失败运行后遗留并进入之后的 commit。 ### 推广契约 @@ -52,7 +52,7 @@ flowchart TD ### 机制所在位置 -工具源码、适配器二进制文件、提供方凭据和预期的每日调度器保存在维护者机器上,不会提交到本仓库。本文规定协议,参考实现属于私有基础设施。该机制只服务于由单个运维方维护的一项 skill,因此,让机制编辑持续接受仓库评审的成本高于来源可追溯性的收益。如果该机制将来移交给第二位维护者,移交工作需要一篇后续 Agent Note(agent 决策记录)来修订本决策;任何接手者都应从运维文档 [docs/cookbook/maintaining-dsh-code-review.md](../../../../docs/cookbook/maintaining-dsh-code-review.md) 入手。 +工具源码、适配器二进制文件、提供方凭据和预期的每日调度器保存在维护者机器上,不会提交到本仓库。本文规定协议,参考实现属于私有基础设施。该机制只服务于由单个运维方维护的一项 skill,因此,持续通过仓库评审审查机制改动的成本高于来源可追溯性的收益。如果该机制将来移交给第二位维护者,移交工作需要一篇后续 Agent Note 来修订本决策;任何接手者都应从运维文档 [docs/cookbook/maintaining-dsh-code-review.md](../../../../docs/cookbook/maintaining-dsh-code-review.md) 入手。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.i18n.yaml b/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.i18n.yaml index c3d518c176..282663d336 100644 --- a/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.i18n.yaml +++ b/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md 2026-07-26-remove-packed-session-fixture-migrator.md: 0a29ef98828ac07d291392d637b0508937c9a9a6 -2026-07-26-remove-packed-session-fixture-migrator.zh.md: 64b994855a7e92d5b0922884b6c66df1b82b6d90 +2026-07-26-remove-packed-session-fixture-migrator.zh.md: aa9aece710587cef6c84ad9cf078821a2506deb1 diff --git a/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md b/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md index 64b994855a..aa9aece710 100644 --- a/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md +++ b/.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md @@ -12,11 +12,11 @@ Status: proposed ## 提案 -最新清单确认不再有任何开放 PR(Pull Request)需要转换会话格式 JSONL 后,移除临时 CLI `scripts/migrate-packed-session-fixtures.ts`,以及根包(package)提供的 `migrate:packed-session-fixtures` 命令。在同一变更中,移除测试政策、ACP 快照 README 和已实现打包行 Agent Note(agent 决策记录)中指向该过渡命令的链接,并将 `scripts/session-fixture-layout.snapshot.ts` 中仅适用于该命令的修复指引替换为与具体命令无关的规范布局指引。 +在实时清单确认不再有任何开放 PR(Pull Request)需要转换会话格式 JSONL 后,移除临时 CLI `scripts/migrate-packed-session-fixtures.ts`,以及根包提供的 `migrate:packed-session-fixtures` 命令。在同一变更中,移除测试政策、ACP 快照 README 和已实现打包行 Agent Note 中指向该过渡命令的链接,并将 `scripts/session-fixture-layout.snapshot.ts` 中仅适用于该命令的修复指引替换为与具体命令无关的规范布局指引。 保留 `scripts/session-fixture-layout.ts`、其单元测试和 `scripts/session-fixture-layout.snapshot.ts`。它们定义并强制执行永久规范布局;只有面向分支的写入器是临时机制。 -移除命令前,每个受影响分支都要合并当前 `master`,运行一次迁移器,单独提交由此产生的仅 fixture 重写,并验证仓库级快照布局检查通过。已关闭或被取代的分支无需迁移。 +移除命令前,每个受影响分支都要合并当前 `master`,运行一次迁移器,将由此产生且仅包含 fixture 重写的改动单独提交,并验证仓库级快照布局检查通过。已关闭或被取代的分支无需迁移。 ## 曾考虑的替代方案 @@ -24,15 +24,15 @@ Status: proposed **随 CLI 一同移除规范布局转换模块。** 该模块不是过渡残留:快照 CI 使用它发现未来 fixture、解码混合物理记录,并与规范打包表示进行比较。移除该模块也会移除强制机制。 -**打包行进入 `master` 后立即删除命令。** 较旧的开放分支在重新定向后,只能使用临时脚本或手动重新生成快照,这会增加冲突风险,也会让解码事件保真度更难评审。 +**打包行进入 `master` 后立即删除命令。** 较旧的开放分支在调整目标分支后,只能使用临时脚本或手动重新生成快照,这会增加冲突风险,也会让解码事件保真度更难评审。 ## 验收标准 -- 最新开放 PR 清单未发现任何仍依赖临时迁移命令处理会话格式 JSONL 改动的分支。 +- 实时开放 PR 清单未发现任何仍依赖临时迁移命令处理会话格式 JSONL 改动的分支。 - 临时 CLI、根包命令、所有分支收敛链接与仅适用于该命令的门禁诊断均不存在;永久规范布局转换器、单元测试和快照检查仍然保留。 - `pnpm run test:snapshot`、`pnpm run doc-sync`、lint 和空白校验在没有临时命令的情况下通过。 - 当前文档仅描述打包默认值和永久规范布局强制机制。 ## 风险 -若开放分支清单不完整,命令消失后,贡献者可能会受困于大规模非打包 fixture 冲突。因此,移除操作取决于实时 PR 证据,而不是经过的时间。保留命令过久的运维成本较低,但会模糊哪一种机制才是永久机制。 +若开放分支清单不完整,命令消失后,贡献者可能会陷入大规模的非打包 fixture 冲突。因此,移除操作取决于实时 PR 证据,而不是经过的时间。保留命令过久的运维成本较低,但会模糊哪一种机制才是永久机制。 diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml new file mode 100644 index 0000000000..5418a920e5 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.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/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md +2026-08-04-artifact-first-npm-baseline-publication.md: e79a988531ac266d72f7d2c036bdd10f3e4520a9 +2026-08-04-artifact-first-npm-baseline-publication.zh.md: d7322547c665700aeffce77ea0bb39f622220fe3 diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md new file mode 100644 index 0000000000..e79a988531 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md @@ -0,0 +1,114 @@ +# Agent Note: Artifact-first NPM baseline publication + +Status: proposed + +English | [中文](2026-08-04-artifact-first-npm-baseline-publication.zh.md) + +## Problem + +Runnable source in the monorepo does not prove that published packages are runnable. Workspace links, TypeScript paths, tsx source loading, and residual `lib/` files in the working tree can supply files or dependencies that are absent from a published tarball. Existing built-artifact tests still read `lib/` directly from the working tree, so they do not verify what `package.json#files` selects or the layout produced by package-manager installation. An execution that succeeds in development mode can therefore be published without a required bundle chunk, declaration, configuration file, or asset. + +Publishing many mutually dependent `@deepseek-ai` packages also creates a set-consistency problem. If a script publishes each package immediately after packing it, a later pack or validation failure leaves the first part of an unusable baseline in the registry. The npm registry has no cross-package transaction, so “publish once” cannot promise an atomic commit. It can promise that the complete publication set is packed and validated before any remote write, then published from that immutable set by one resumable orchestration command. + +The current baseline also requires a person to derive versions, authenticate, pack, publish, and retry from a local machine. A later GitHub Actions workflow must reuse the same release bundle and validation logic. It must not rebuild a different, untested set of tarballs after publication is approved. + +## Proposal + +The publication flow uses an immutable release bundle as its boundary. The pack phase builds every target package from one fixed Git commit, creates every tarball, validates tarball contents, and passes an installed-artifact integration test. The publish phase reads only those tarballs and their manifest and is forbidden from rebuilding or repacking. + +The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list. + +The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time. + +The pack phase runs in this order: + +1. Resolve the ref to an immutable commit, capture the UTC timestamp, derive the version from that commit's root manifest, and display the commit, timestamp, version, tag, registry, and output path. Both `pack` and `release` wait for Enter at this point before expensive work; `--yes` skips this confirmation for automation. +2. Install the frozen lockfile in an isolated detached worktree and run source-manifest publication constraints before staging. Uncommitted files and old build output from the caller's working tree must not affect publication. +3. Stage every target manifest with the derived version, remove its publication-time `private` marker, and rewrite internal workspace dependencies in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies` to the same exact version. +4. Build the target commit completely, then run publint and built-package invariants. +5. Pack every package in the target set without performing a registry write. +6. Inspect each tarball's package manifest, file inventory, internal dependency versions, name, and version, rejecting missing, duplicate, or extra tarballs. +7. Generate a release manifest and checksums containing the commit, version, tag, registry, and each package's tarball path, SHA-256, and npm integrity. +8. Install an isolated consumer from the local tarballs and run the installed-artifact probes available in the current implementation; expand those probes to the complete artifact-plane integration matrix defined below. +9. Print one directly executable publish command only after the complete set passes. The pack command itself always remains free of remote writes. + +The local `release` command composes pack and publish. It first uses the pack confirmation above to fix the expected timestamp and version, then waits for Enter again after a successful pack before publishing the same manifest; `release --yes` skips both confirmations. Separate `pack` and `publish --manifest` operations remain the primitives used by split CI jobs and recovery. + +## Current implementation boundary + +The checked-in pack command implements fixed-commit staging, exact internal dependency pins, static and tarball payload checks, the immutable manifest, and an isolated npm installation with every release tarball as a local top-level dependency. It runs the installed `dsh --version` and `dsh --dump-default-config` entries under plain Node, then starts the installed default TUI in a POSIX PTY, waits for its `main-session-` ready signal, and exits through `/exit` before printing the publish command. Publish supports integrity-based resumption, separates read-only registry verification from the authenticated identity check, and finishes with a complete remote integrity and dist-tag verification pass. + +Pull-request CI does not invoke the pack command; the installed-entry probes are local release checks rather than merge gates. Credential-free CI execution, package-owned probes for every other bin and public runtime entry, workflow-artifact transfer, and the protected publication job remain proposal scope. + +## Publication payload contract + +Published packages contain only build artifacts required by consumers. `package.json#files` must not include `src` or `lib/types/**/*.d.ts.map`; an independent tarball-content gate must also confirm that no `package/src/**` or `package/**/*.d.ts.map` entry exists, preventing manifest patterns or pack behavior from bypassing the static constraint. Runtime JavaScript, `.d.ts` declarations, configuration, assets, worker files, and dynamic bundle chunks must cover the actual entrypoint closure. + +Source manifests may retain `exports["./src/*"]` for this repository's source-plane resolution. That export does not place source in the publication payload and is not a consumer contract of the published package. Static gates must check the source plane and publication payload separately: deleting the source export must not hide broken workspace resolution, and publishing `src` must not repair missing build artifacts. + +Every tarball must be free of `workspace:` specifiers, and every internal dependency and peer dependency that points into the publication set must equal the exact derived version; `^`, `~`, and other semver ranges must not cross commit baselines. Except for `exports["./src/*"]`, which is explicitly source-plane-only, every consumer entry declared by the package manifest must point to a file present in the tarball. Dynamic imports, runtime-computed paths, and non-exported assets cannot be validated solely from the manifest and require installed execution. + +## Artifact-plane integration test + +The integration test runs after all tarballs exist and before any publish. It creates a fresh temporary project outside the monorepo, installs the declared dependency closure through local `.tgz` files from the release manifest, and executes from that installation. The test must use plain Node and package-manager-produced `node_modules`; tsx, tsconfig paths, workspace links, repository source paths, working-tree `lib/`, and the same version from the published registry are forbidden resolution inputs. The test also asserts that critical modules and bins resolve to real paths inside the temporary consumer. + +Installation uses the client behavior selected for this publication. Registry uploads must use the `npm` CLI because the private registry accepts only the npm client; pnpm may still orchestrate builds. Tarball tests must neither publish these packages to the real registry first nor repack after testing. + +The test covers at least these execution surfaces: + +- Installed `@deepseek-ai/dsh` runs `dsh --version` and `dsh --dump-default-config` successfully under plain Node, covering the static CLI entry and one dynamic mode entry. +- Installed default `dsh` completes one keyless TUI startup in a PTY and exits under test control after reaching a defined ready signal. This path must load the real TUI dynamic chunk, so a missing publication file such as `lib/tui-*.js` fails the gate. +- Every other published `bin` defines a package-owned smoke command that neither reaches a real service nor modifies user state. Different CLIs are not forced to share `--help`; the test runs the actual installed entry and checks its agreed exit or ready signal. +- Node-compatible public runtime entries load from the installation. Browser, worker, or host-protocol-only entries use matching isolated fixtures, but their inputs must still be the current tarballs exclusively. + +These tests prove executability; they do not replace unit tests, snapshots, real-API e2e, or publint. Test fixtures should reuse behavior assertions from existing built-bin and PTY scenarios while changing the entry to the tarball installation. A test that runs working-tree `lib/bin.js` directly does not satisfy this gate. + +## Publication and recovery + +The publish command validates the release manifest, every local checksum, the target registry, `npm ping`, and `npm whoami` before uploading tarballs in a deterministic order. It accepts only a manifest created by the pack phase, never workspace directories. The default registry is `https://registry.npm.harnessment.com/`; every publish passes the registry and derived tag explicitly so a user-level `.npmrc` cannot redirect the operation. + +npm provides no multi-package atomic transaction, so uploads still occur package by package. The orchestrator reduces the failure surface through idempotent recovery: upload when remote `<name>@<version>` does not exist; skip when it exists with the release manifest's integrity; fail immediately when it exists with different content. Dist-tag inspection reads tag assignments without resolving a default tag's target, so an unrelated dangling tag cannot block recovery. At completion it must confirm every package version's integrity and every dist-tag against the release version. The workflow reports success only when the complete set passes final verification. + +If pack, tarball inspection, or installed-artifact integration fails, the registry must receive zero writes. If publish fails after a partial upload, the operator reruns publish with the same release manifest and must not repack into another timestamped version instead of recovering. Only a code or build-input change that requires different tarballs reruns the complete pack and test flow. + +## GitHub Actions integration + +GitHub Actions separates a credential-free pack-and-test job from a protected publish job. The first checks out the exact commit, invokes the same pack entry used locally, runs tarball-consumer tests, and uploads the complete release bundle as a workflow artifact. The second depends on the first, downloads that workflow artifact, revalidates the manifest and checksums, and invokes the same publish entry. It cannot rebuild after checkout. + +Pull requests and ordinary pushes may run the credential-free pack-and-test signal so payload regressions surface before merge. Actual private-registry publication starts as `workflow_dispatch` with only a target ref as input; the pack job creates the UTC timestamp, while the base version, short SHA, tag, registry, and package inventory derive from repository state or version-controlled configuration. Stable-release triggering is outside this baseline proposal. + +The registry token is injected only into the publish job, which uses a protected GitHub Environment to govern human approval, allowed branches or tags, and concurrency. The pack-and-test job cannot read publication credentials. The workflow artifact may have a short retention period, but the publish job must use the bundle produced by the same workflow run instead of locating tarballs from an untrusted source by version. + +## Alternatives considered + +**Publish recursively from the workspace.** Rejected because it interleaves packing and registry writes, cannot prove the complete set before the first write, and allows workspace resolution and caller working-tree state to influence publication. + +**Test only built `lib/` in the working tree.** Rejected because that validates the build tree rather than the tarball selected by `package.json#files`. A dynamic chunk present in the working tree but omitted from the tarball is exactly the failure this proposal must catch. + +**Run only `dsh --help`.** Rejected because Commander can print help and exit before loading the TUI, Web, or headless dynamic entry. It does not prove the default production startup path is complete. + +**Publish `src` and declaration maps to reduce missing-file risk.** Rejected because the source plane is not a production-runtime fallback. Expanding the payload hides bundle-closure errors and turns local debugging outputs into accidental publication contracts. + +**Require truly atomic cross-package publication.** Rejected because the npm registry has no such transaction. An immutable release bundle, complete pre-publication validation, integrity comparison, and idempotent recovery provide an implementable boundary while retaining the explicit limitation that partial uploads can be briefly visible. + +**Rebuild in the publish job after approval.** Rejected because the tested and uploaded tarballs would lose content identity. The workflow artifact and checksums must carry the test inputs directly into publication. + +## Acceptance criteria + +- One pack entry discovers every target under `packages/*/*` and `apps/*` from a fixed commit, derives and displays a version from the UTC second and short commit before waiting for Enter, generates the complete release bundle before any registry write, and prints one copyable publish command; `release` waits again after packing, while `--yes` skips both confirmations. +- Static manifest and tarball-content gates both reject published `src` and `.d.ts.map` while source manifests retain `exports["./src/*"]`. +- The release bundle records the complete package set, commit, derived version, tag, registry, and per-tarball integrity; every internal dependency is pinned exactly to that version, and publish consumes only that bundle without rebuilding. +- An isolated integration test installs from local tarballs and starts the installed default `dsh` TUI under plain Node; deleting any required dynamic chunk makes the test fail deterministically. +- Every published bin and applicable public runtime entry has post-tarball-install execution coverage, with resolution paths proving that no monorepo fallback occurred. +- Publish safely resumes from the same manifest after partial success: matching integrity is skipped, conflicting integrity is rejected, and final verification requires every version and tag to agree. +- A credential-free GitHub Actions job creates and tests the bundle, a protected job uploads the identical bundle, and the publication token exists only in the latter. + +## Risks + +Full packing, installation, and startup add CI time and workflow-artifact volume. The implementation should cache external dependencies and the pnpm store, but it must not cache or reuse installed workspace output for target packages. Safe consumer probes can run in parallel to reduce latency. + +Installing every tarball as a temporary project's top-level dependency can hide an undeclared internal dependency. The test generator should install each tested application's declared recursive closure and retain existing dependency gates. For `@deepseek-ai/dsh`, whose dependency surface approaches the full set, package-manifest and static graph checks remain necessary to detect undeclared edges. + +Platform-specific optional dependencies, native addons, PTYs, and browser entries may require platform-owned probes. The first phase must cover primary `dsh` startup on the publication Linux runner and one local macOS path, then expand the matrix with the actual publication platforms. Skipping an unstable probe must not move a production path outside the gate. + +Recovery cannot remove npm's partial visibility. During a failed publication, the registry may briefly contain only some package versions from the bundle. Operators and automation must treat the final bundle verification, not one successful `npm publish`, as the baseline-availability signal. diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md new file mode 100644 index 0000000000..d7322547c6 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md @@ -0,0 +1,114 @@ +# Agent Note: 以产物为先的 NPM 基线发布 + +Status: proposed + +[English](2026-08-04-artifact-first-npm-baseline-publication.md) | 中文 + +## 问题 + +monorepo 中可运行的源码并不能证明发布后的包可运行。workspace link、TypeScript paths、tsx 源码加载和工作树里残留的 `lib/` 都可能补上发布 tarball 中缺失的文件或依赖。即使现有构建产物测试使用普通 Node,它仍直接读取工作树中的 `lib/`,没有验证 `package.json#files` 最终选中了什么,也没有验证包管理器安装后的文件布局。一次开发模式正常的执行因此可能发布为缺少 bundle chunk、声明文件、配置或资源的包。 + +发布多个互相依赖的 `@deepseek-ai` 包还会产生集合一致性问题。如果脚本每 pack 一个包就立即 publish,那么后续 pack 或验证失败时,注册表中已经存在无法作为完整基线使用的前半组版本。npm 注册表没有跨包事务,因此这里的「一次性发布」不能承诺原子提交,只能承诺在任何远端写入前完整生成并验证发布集合,再由一个可恢复的编排命令发布这个不可变集合。 + +当前基线还需要人工在本机完成版本派生、认证、pack、发布和重试。后续 GitHub Actions 工作流必须复用同一套发布包与验证逻辑,不能在批准发布后重新构建另一组未经消费方测试的 tarball。 + +## 提案 + +发布流程以一个不可变的 release bundle(发布包集合)为边界。pack 阶段从一个确定的 Git commit 构建全部目标包、生成全部 tarball、检查 tarball 内容,并通过安装后集成测试;publish 阶段只读取这组 tarball 及其 manifest(元数据清单),禁止重建或重新 pack。 + +目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。 + +预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。 + +pack 阶段按以下顺序执行: + +1. 将 ref 解析成不可变 commit,采集 UTC 时间戳,从该 commit 的根 manifest 派生版本,并显示 commit、时间戳、版本、tag、注册表和输出路径。`pack` 与 `release` 此时都会在昂贵操作开始前等待 Enter;自动化可用 `--yes` 跳过该确认。 +2. 在隔离的 detached worktree 中安装 frozen lockfile,并在暂存发布 manifest 之前运行源码 manifest 发布约束;调用方工作树中的未提交文件和旧构建输出不得参与发布。 +3. 将所有目标 manifest 暂存为派生版本,移除发布时的 `private` 标记,并把 `dependencies`、`devDependencies`、`optionalDependencies` 与 `peerDependencies` 中的内部 workspace 依赖全部改写为同一精确版本。 +4. 完整构建目标 commit,再运行 publint 和已构建包不变式。 +5. 为目标集合中的每个包执行 pack,但不执行任何注册表写入。 +6. 检查 tarball 内的 package manifest、文件清单、内部依赖版本、包名和版本,并拒绝缺失、重复或额外的 tarball。 +7. 生成包含 commit、版本、tag、注册表、每个包的 tarball 路径、SHA-256 与 npm integrity 的 release manifest 和校验和文件。 +8. 从本地 tarball 安装一个隔离消费方,运行当前实现已有的安装态产物探测,并将这些探测扩展为下文定义的完整产物平面集成测试矩阵。 +9. 仅当整个集合通过时输出一个可直接执行的 publish 命令;pack 命令本身始终保持无远端写入。 + +本地 `release` 命令组合 pack 与 publish。它先通过上述 pack 确认确定预期时间戳和版本,再在 pack 成功后等待第二次 Enter,随后发布同一 manifest;`release --yes` 跳过两次确认。独立的 `pack` 与 `publish --manifest` 仍是 CI 分 job 和断点恢复使用的基础操作。 + +## 当前实现边界 + +已提交的 pack 命令实现了固定 commit 暂存、内部依赖精确固化、静态与 tarball payload 检查、不可变 manifest,以及把每个发布 tarball 都作为本地顶层依赖的隔离 npm 安装。它在输出 publish 命令前,用普通 Node 运行安装后的 `dsh --version` 与 `dsh --dump-default-config` 入口,再在 POSIX PTY 中启动安装后的默认 TUI,等待其 `main-session-` 就绪信号,并通过 `/exit` 退出。Publish 支持按 integrity 恢复,将只读注册表验证与认证身份检查分离,并以完整的远端 integrity 和 dist-tag 验证结束。 + +拉取请求 CI 不会调用 pack 命令;安装态入口探测属于本地发布检查,而不是合并门禁。免凭据 CI 执行、其他每个 bin 与公开运行时入口的包自有探测、workflow artifact 传递及受保护 publish job 仍属于提案范围。 + +## 发布 payload 契约 + +发布包只携带消费方需要的构建产物。`package.json#files` 禁止包含 `src` 和 `lib/types/**/*.d.ts.map`;tarball 内容门禁还要独立确认不存在任何 `package/src/**` 与 `package/**/*.d.ts.map`,避免 manifest pattern 或 pack 行为绕过静态约束。运行时 JS、声明文件 `.d.ts`、配置、资源、worker 文件和 bundle 动态 chunk 必须按实际入口闭包收齐。 + +源码 manifest 可以保留 `exports["./src/*"]`,供本仓库的源码平面解析使用;该 export 不代表源码会进入发布 payload,也不属于已发布包的消费方契约。静态门禁必须分别检查源码平面与发布 payload,不能通过删除 source export 来掩盖错误的 workspace 解析,也不能通过发布 `src` 来修补缺失的构建产物。 + +每个 tarball 必须不含 `workspace:` specifier,并且所有指向本次发布集合的内部依赖与对等依赖(peer dependency)都必须精确等于本次派生版本,禁止使用 `^`、`~` 或其他 semver 范围跨越 commit 基线。除了明确仅供源码平面使用的 `exports["./src/*"]`,package manifest 中声明的每个消费方入口都必须指向 tarball 内存在的文件;动态 import、运行时拼接路径和非 export 资源不能只靠 manifest 检查,必须由安装后执行覆盖。 + +## 产物平面集成测试 + +集成测试在全部 tarball 生成后、任何 publish 之前运行。它在 monorepo 外创建一个全新临时项目,通过本次 release manifest 中的本地 `.tgz` 文件安装声明依赖闭包,并从安装目录执行。测试必须使用普通 Node 与包管理器生成的 `node_modules`;禁止 tsx、tsconfig paths、workspace link、仓库源码路径、工作树 `lib/` 和已发布注册表中的同版本包参与解析。测试还要断言关键模块与 bin 的真实路径位于临时消费方内。 + +安装使用本次发布选择的客户端行为。注册表上传必须使用 `npm` CLI,以满足私有注册表只接受 npm 客户端的策略;构建编排仍可使用 pnpm。tarball 测试不得先把这些包发布到真实注册表,也不得在测试后重新 pack。 + +测试至少覆盖以下执行面: + +- `@deepseek-ai/dsh` 安装后的 `dsh --version` 与 `dsh --dump-default-config` 在普通 Node 下成功,分别覆盖静态 CLI 入口和一个动态模式入口。 +- 安装后的默认 `dsh` 在 PTY 中完成一次无密钥 TUI 启动,到达既定 ready 信号后由测试受控退出。这条路径必须加载真实 TUI 动态 chunk,因此缺少类似 `lib/tui-*.js` 的发布文件会使门禁失败。 +- 每个其他已发布 `bin` 都定义一个不会访问真实服务或修改用户状态的包级冒烟命令。不同 CLI 不强制共用 `--help`;测试必须运行其真实安装入口并检查约定的退出或 ready 信号。 +- Node 兼容的公开运行时入口从安装目录加载;浏览器、worker 或必须由宿主协议驱动的入口使用对应的隔离 fixture(测试前置数据),但输入仍只能是本次 tarball。 + +这些测试验证可执行性,不替代单元测试、快照、真实 API e2e 或 publint。测试 fixture 应复用现有 built-bin 和 PTY 场景的行为断言,但必须把入口改为 tarball 安装结果;直接运行工作树 `lib/bin.js` 的测试不能算作本门禁。 + +## 发布与恢复 + +publish 命令先验证 release manifest、所有本地校验和、目标注册表、`npm ping` 和 `npm whoami`,再按确定顺序上传 tarball。命令只接受 pack 阶段生成的 manifest,不接受 workspace 目录作为发布输入。默认注册表为 `https://registry.npm.harnessment.com/`,每次 publish 都显式传入注册表和派生 tag,避免用户级 `.npmrc` 改变目标。 + +npm 不提供多包原子事务,上传仍会逐包发生。编排器通过幂等恢复缩小失败面:若远端不存在 `<name>@<version>` 就上传;若已存在且 integrity 与 release manifest 相同就跳过;若已存在但内容不同就立即失败。dist-tag 检查只读取 tag 映射,不解析默认 tag 指向的版本,因此即使无关 tag 指向的版本已不存在,也不会阻断恢复。完成后必须逐包确认版本 integrity 和 dist-tag 都指向本次版本,只有整个集合通过最终验证,工作流才报告发布成功。 + +如果 pack、tarball 检查或安装后集成测试失败,注册表必须保持零写入。如果 publish 在部分上传后失败,操作者使用同一 release manifest 重跑 publish 命令来恢复,不得重新 pack 并生成另一个时间戳版本来代替恢复。修复代码或改变构建输入后需要不同 tarball 时,才重新执行完整 pack 与测试。 + +## GitHub Actions 集成 + +GitHub Actions 分为无凭据的 pack-and-test job 与受保护的 publish job。前者检出精确 commit,调用与本地相同的 pack 入口,运行 tarball 消费方测试,并上传完整 release bundle 作为 workflow artifact。后者依赖前者成功,从 workflow artifact 下载 bundle、重新校验 manifest 和校验和,再调用同一个 publish 入口;它不能检出后重新构建。 + +PR 与普通 push 可以运行无凭据的 pack-and-test 信号,从而在合并前发现 payload 回归。实际私有注册表发布先通过 `workflow_dispatch` 提供,输入只包括目标 ref;UTC 时间戳由 pack job 生成,基础版本、短 SHA、tag、注册表和包清单都由仓库状态或受版本控制的配置派生。稳定发布触发方式不在本基线提案范围内。 + +注册表 token 只注入 publish job,并由受保护 GitHub Environment 控制人工批准、允许的分支或 tag 以及并发。pack-and-test job 不得读取发布凭据。workflow artifact 的保留期可以较短,但 publish job 必须使用同一 workflow run 生成的 bundle,不能按版本号从不受信任的位置寻找 tarball。 + +## Alternatives considered + +**从 workspace 直接递归 publish。** 不采用,因为命令会把 pack 与注册表写入交错,无法在第一次写入前证明整个集合完整,也容易让 workspace 解析与调用方工作树状态影响发布结果。 + +**只测试工作树中构建后的 `lib/`。** 不采用,因为这验证的是构建树,不是 `package.json#files` 选出的 tarball。工作树中存在而 tarball 中漏掉的动态 chunk 正是本提案必须捕获的失败。 + +**只运行 `dsh --help`。** 不采用,因为 Commander 可以在加载 TUI、Web 或 headless 动态入口之前输出帮助并退出。它无法证明默认生产启动路径完整。 + +**把 `src` 和声明映射一起发布以降低漏文件风险。** 不采用,因为源码平面不是生产运行时的后备路径;扩大 payload 会掩盖 bundle 闭包错误,并把本地调试产物变成无意的发布契约。 + +**要求真正的跨包原子发布。** 不采用,因为 npm 注册表没有相应事务。不可变 release bundle、发布前全量验证、integrity 比对与幂等恢复提供可实现的边界,同时明确保留部分上传短暂可见的限制。 + +**在批准后由发布 job 重新构建。** 不采用,因为测试通过的 tarball 与实际上传的 tarball 将不再具有内容身份。workflow artifact 与校验和必须把测试输入直接传给发布步骤。 + +## Acceptance criteria + +- 一个 pack 入口从确定 commit 发现 `packages/*/*` 和 `apps/*` 的全部目标包,以 UTC 秒级时间戳与短 commit 生成并显示版本,再等待 Enter;它在任何注册表写入前生成完整 release bundle,并输出一个可复制的 publish 命令;`release` 在 pack 后再次等待,`--yes` 跳过两次确认。 +- 静态 manifest 门禁和 tarball 内容门禁都拒绝发布 `src` 与 `.d.ts.map`,同时保留源码 manifest 中的 `exports["./src/*"]`。 +- release bundle 记录完整包集合、commit、派生版本、tag、注册表和逐 tarball integrity;所有内部依赖都精确固化到该版本,publish 只消费该 bundle,绝不重建。 +- 一个隔离集成测试从本地 tarball 安装消费方,并用普通 Node 启动安装后的默认 `dsh` TUI;删除任一所需动态 chunk 会使该测试稳定失败。 +- 所有已发布 bin 和适用的公开运行时入口都有 tarball 安装后的执行覆盖,且解析路径证明没有回退到 monorepo。 +- publish 可在部分成功后用同一 manifest 安全重跑;相同 integrity 被跳过,不同 integrity 被拒绝,最终验证要求所有版本与 tag 一致。 +- GitHub Actions 的无凭据 job 生成并测试 bundle,受保护 job 上传完全相同的 bundle,发布 token 只存在于后者。 + +## Risks + +全量 pack、安装和启动会增加 CI 时间与 workflow artifact 体积。实现应缓存外部依赖和 pnpm store,但不得缓存或复用目标包的已安装 workspace 输出;并行执行安全的消费方 probe 可以降低时延。 + +把所有 tarball 都安装为临时项目的顶层依赖可能掩盖未声明的内部依赖。测试生成器应按被测应用的声明式递归闭包安装,并结合现有依赖门禁;对依赖面接近全集的 `@deepseek-ai/dsh`,仍需依靠 package manifest 与静态图检查发现未声明边。 + +不同平台的 optional dependency、native addon、PTY 与浏览器入口可能需要平台专属 probe。第一阶段至少在发布所用 Linux runner 和一个本地 macOS 路径上覆盖主 `dsh` 启动,后续矩阵按实际发布平台扩展;不能用跳过不稳定 probe 的方式把生产路径移出门禁。 + +恢复机制不能消除 npm 的部分可见性。发布失败期间,注册表可能短暂含有本次版本的一部分包;操作者与自动化必须以最终 bundle 验证结果而非单个 `npm publish` 的成功作为基线可用信号。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 62fc537c2f..b7cfbde315 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.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/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md 2026-07-04-prune-dead-core-spine-surface.md: 473f655ab0944b43f9b1193413801eb7a80286d8 -2026-07-04-prune-dead-core-spine-surface.zh.md: 00c6f7acca403c937ff765b6d73d3d0fa0c3153a +2026-07-04-prune-dead-core-spine-surface.zh.md: 3389c0161efa78252c9810d64f46d0cd8a7a034c diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 00c6f7acca..3389c0161e 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -6,26 +6,26 @@ Status: proposed ## 问题 -若干包(package)根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 +若干包根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 -生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note 行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: | 接口 | 生产证据 | 简化方式 | | --- | --- | --- | | `SurfaceManager.invalidate()` | 只有其单元测试调用它;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除它及其不可能触发的整体替换契约。 | | `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过调用/会话事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不可能不一致的测试。 | -| `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 | -| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;工作流 Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | +| `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 将返回类型和接口类型设为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 | +| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 所有通过包名导入的消费方都使用默认引擎;工作流 Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | | `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 | | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 | | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | -| `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 | +| `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制机制和测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 | | `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | -| `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 上已有的是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动 region seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | +| `compactRegion` 的独立 `session` 参数 | 固定调用方传入的对象就是 `agent.session` 中已有的对象;模型可见的 mount API 也可以调用该方法,但同时接受两个独立对象,会让挂载的插件传入不一致的组合。 | 保留手动 region seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | | `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | -| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 Agent Note 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | +| `BasicCompactService` 的估算/摘要方法可见性 | 没有包外生产调用者调用这五个方法;已实现的 Agent Note 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | | `CodeLogEntry.source`/`level` 与 `RunCodeMeta.dispatches` | 每个生产消费方都将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta 的 dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | | `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 Code Mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | | `ToolNotFoundError.toolName`、`SystemPrompt.config` 与 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,保留错误消息、已解析的配置行为和任务生命周期。 | diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml index a50ddc4f12..cdebeeac5c 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md 2026-07-19-make-jsonrpc-directional.md: 910b4988aca34dec499b2e34cb2a42042c81b0cb -2026-07-19-make-jsonrpc-directional.zh.md: acc1b433e14c65da2ed37dff7a5fe181ebee36ad +2026-07-19-make-jsonrpc-directional.zh.md: 9810cf758faa078583955ba541e2b2fe9ad9c855 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md index acc1b433e1..9810cf758f 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。共享传输层(现为 `dsh-sdk-protocol`,由服务端与 TypeScript SDK 客户端共用,后者行使出站请求/入站通知方向)仍实现着没有任何端点使用的两个半边:服务端发起的请求与客户端发起的通知。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 +JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。共享传输层(现为 `dsh-sdk-protocol`,由服务端与 TypeScript SDK 客户端共用,后者行使出站请求/入站通知方向)仍实现着没有任何端点使用的两个半边:服务端发起的请求与客户端发起的通知。Python SDK 发送请求并接收响应或通知,却还会把来自服务端、但未使用的入站请求放入队列,并公开响应辅助方法。 `session/prompt` 还会用两种协议结构报告同一个已结束轮次。服务端先发出 `session.finished`,再返回常量 `{ accepted: true }`;Python SDK 丢弃该响应,转而等待通知以取得状态。响应只有在处理函数返回后才会写入,因此在同一条有序流上,通知必然先于这个常量响应。 @@ -16,12 +16,12 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协 按实际角色收窄两个端点。服务端保留入站请求、出站响应和出站通知;TypeScript 与 Python 客户端保留出站请求以及入站响应或通知。删除没有任何端点使用的方向——服务端发起的请求与客户端发起的通知。 -在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 +在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久化会话事件仍是最终响应重建的真源。 ## 实施计划 -1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前或可合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 -2. 在 `packages/sdk/sdk-protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应加入站通知(TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前已有或可通过声明合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 +2. 在 `packages/sdk/sdk-protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应及入站通知(TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 5. 用按方向的覆盖替换 `packages/sdk/sdk-protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/sdk/sdk-client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index 8ca0473ebe..66b0331800 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md 2026-06-11-deterministic-and-stress-testing.md: d9977be835af05f9ee303b63ec6015bc9e153170 -2026-06-11-deterministic-and-stress-testing.zh.md: eff9eecb699344dff388bafec270f6b6677f71ee +2026-06-11-deterministic-and-stress-testing.zh.md: 263e69f85a1cd8ee47da07210e513cab272d1a44 diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index eff9eecb69..263e69f85a 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 确定性测试、回放不变式 fixture(测试前置数据)与竞态压力测试 +# Agent Note: 确定性测试、回放不变式 fixture 与竞态压力测试 Status: proposed @@ -13,8 +13,8 @@ Status: proposed 三项措施: 1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(既有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest 的 fake timer。通过 lint 规则禁止 `setTimeout`,适用范围是 `packages/*/tests`,白名单辅助模块除外。 -2. **通用回放 fixture。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 -3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都视为 bug 修复,绝不靠重试掩盖。 +2. **通用回放 fixture(测试前置数据)。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 +3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定现象都视为需要修复的 bug,绝不靠重试掩盖。 ## 计划 @@ -24,7 +24,7 @@ Status: proposed - 不再使用 `setTimeout`;lint 规则在 `packages/*/tests` 中强制执行,白名单辅助模块除外。 - 共享 harness 将每个测试的会话日志回放到全新的 `Session` 中,并自动断言 `deriveMessages()` 相等,覆盖整个套件。 -- 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定测试作为 bug 分诊,绝不靠重试掩盖。 +- 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定现象一律按 bug 分诊,绝不通过重试消除。 ## 风险 diff --git a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml index d593656c04..19a1c9a601 100644 --- a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/proposed/testing/2026-06-11-mutation-testing.md 2026-06-11-mutation-testing.md: 591d9012644a19ee2c67a916b63092d79f78db1f -2026-06-11-mutation-testing.zh.md: 9c22ed2f42e5c44e6be98f132614886bbdb188fd +2026-06-11-mutation-testing.zh.md: ebbeb561bd7bfee659882d25c808f2cb9a1aa825 diff --git a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md index 9c22ed2f42..ebbeb561bd 100644 --- a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md @@ -14,23 +14,23 @@ Status: proposed - **PR(Pull Request)范围的增量运行**(仅变更文件),作为一个 CI job。调优后速度足以作为合并门禁。 - **每夜全量运行**,跟踪变异分数;先记录基线,再将阈值设为观测到的基线并只升不降(与覆盖率策略一致:阈值只收紧)。 -- 存活的变异体是待办项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个形态良好的自主循环。 +- 存活的变异体是待办项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个适合自主执行的闭环。 - 等价变异体(可证明不改变行为的)加注释排除并附理由,与 `/* v8 ignore */` 策略一致。 ## 计划 -1. 添加 Stryker 配置,范围限定在一个包(package),即 llm(最小、最具算法性),并测量运行时间。 +1. 添加 Stryker 配置,范围限定在一个包,即 llm(最小、最具算法性),并测量运行时间。 2. 扩展到所有包;在配置中记录基线分数。 3. 接入每夜 job;运行时间可接受后再添加 PR 范围的增量 job。 ## 验收标准 -- Stryker 配置在 `packages/*/src` 上以 vitest runner 运行;每夜 job 记录变异分数,当分数低于记录的基线时,通过只升不降的阈值使运行失败。 -- PR 范围的增量运行在运行时间可接受后作为合并门禁;或者明确保持仅每夜运行,并将该结论记录于此。 +- Stryker 配置在 `packages/*/src` 上以 vitest runner 运行;每夜 job 记录变异分数并采用只升不降的阈值,分数低于已记录的基线时任务失败。 +- PR 范围的增量运行在运行时间可接受后作为合并门禁;否则明确决定只保留每夜运行,并将该结论记录于此。 - 等价变异体带有注释排除及理由,与 `/* v8 ignore */` 策略一致。 ## 风险 -运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 范围的运行始终过慢,则保持仅每夜运行,依赖分数只升不降的机制。 +运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 范围的运行始终过慢,则保持仅每夜运行,依靠变异分数阈值的只升不降机制。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml index 2dc0338121..34eeca606c 100644 --- a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md 2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: e636bba5b7d69774b2eb5eccea1f3272211dedf2 diff --git a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md index 3932f73a2b..e636bba5b7 100644 --- a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip -Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划 +Status: rejected — landstrip 未经实战检验(驳回时问世仅数天,只有一名维护者,GitHub 星标约 48 个);安全不变式级的依赖必须经过广泛采用的验证,因此 win32 层级维持自研启动器的原计划 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 @@ -8,27 +8,27 @@ Status: rejected — landstrip 未经实战检验(问世仅数天、单一维 [沙箱决策](../../implemented/feature/2026-07-06-sandbox.md)将 `PLATFORM_CHAINS.win32` 留空,并计划用「AppContainer/受限令牌(restricted-token)家族的一个约束运行器,按 `node-addon-landlock-run` 模板从其独立仓库发布」来填充——一个估计约 1,500 行、需要自研编写并维护的新仓库(landlock-run 子树约为 1,460 行 C/TS/脚本/测试,外加文档与 CI)。 -自那份决策记录写成以来,出现了一个持续维护的第三方运行器:`@landstrip/landstrip`(npm 包,活跃开发中,Rust 内核,附带按平台预构建的 `optionalDependencies`)覆盖 Linux 上的 Landlock + seccomp、macOS 上的 Seatbelt,以及 Windows 上的 AppContainer/受限用户,支持 JSON/YAML 策略输入和基于 trap-fd 的拒绝上报通道。它与 bwrap 一样采用 exec 包装方式,因此无需触碰 Linux/macOS 梯级即可契合链的 `confine(argv)` 形态。 +自那份决策记录写成以来,出现了一个持续维护的第三方运行器:`@landstrip/landstrip`(npm 包,活跃开发中,Rust 内核,附带按平台预构建的 `optionalDependencies`)覆盖 Linux 上的 Landlock + seccomp、macOS 上的 Seatbelt,以及 Windows 上的 AppContainer/受限用户,支持 JSON/YAML 策略输入和基于 trap-fd 的拒绝上报通道。它与 bwrap 一样采用 exec 包装方式,因此无需触碰 Linux/macOS 层级即可契合链的 `confine(argv)` 形态。 ## 提案 当 Windows 沙箱阶段启动时,在动手编写自研 AppContainer 启动器仓库之前,先评估将 landstrip 的 Windows 后端包装为 `win32` 链运行器。评估必须回答: - **探测合成。** landstrip 没有 `--probe`;链所要求的功能探测契约必须从一次 trap 运行中合成出来。 -- **方言映射。** 拒绝与运行器失败两类 stderr 方言,以及失败即关闭(fail-closed)的退出码分类,都需要显式映射到链的词汇中。 +- **方言映射。** 拒绝与运行器失败两类 stderr 方言,以及失败关闭的退出码分类,都需要显式映射到链的词汇中。 - **许可证。** 其二进制文件采用 LGPL-2.1-or-later 许可;在进入随产品发布的依赖闭包之前需要先做分发审查。 -- **溯源。** 自研启动器的价值在于对一个约 300 行、可审阅的 C 文件施以字节级锁定的原生 CI 溯源;而 landstrip 是单一维护者手中的一组 Rust 二进制文件。对*既有的 Linux 梯级*而言,这笔权衡早有定论——不要替换它(见[沙箱 Note](../../implemented/feature/2026-07-06-sandbox.md)以及该启动器自身摆脱 Rust 依赖的迁移)。而对一个我们尚未构建的梯级,在第三方维护与第二个自研原生仓库之间如何取舍,是一个真正悬而未决的问题。 +- **溯源。** 自研启动器的价值在于一个约 300 行、可完整评审,并由原生 CI 逐字节锁定来源的 C 文件;而 landstrip 是单一维护者手中的一组 Rust 二进制文件。对*既有的 Linux 层级*而言,这笔权衡早有定论——不要替换它(见[沙箱 Agent Note](../../implemented/feature/2026-07-06-sandbox.md)以及该启动器自身移除 Rust 依赖的迁移记录)。而对一个我们尚未构建的层级,在第三方维护与第二个自研原生仓库之间如何取舍,是一个真正悬而未决的问题。 ## 曾考虑的替代方案 -- **按原计划构建自研 AppContainer 启动器。** 若评估在许可证、溯源或探测契合度上不通过,这仍是默认选项;代价是要无限期持有第二个原生安全启动器仓库。 -- **把 Linux Landlock 梯级也换成 landstrip。** 直接否决:沙箱正确性是安全不变量,当前启动器的可审阅性与溯源链是刻意选择的结果,而且它正是出于这一原因才迁移摆脱了 Rust 依赖。 +- **按原计划构建自研 AppContainer 启动器。** 若评估在许可证、溯源或探测契合度上不通过,这仍是默认选项;代价是要长期维护第二个原生安全启动器仓库。 +- **把 Linux Landlock 层级也换成 landstrip。** 直接否决:沙箱正确性是安全不变量,当前启动器的可审阅性与溯源链是刻意选择的结果,而且它正是出于这一原因才迁移摆脱了 Rust 依赖。 ## 验收标准 -- 在任何 Windows 梯级实现开始之前,先有一份评估记录下探测、方言、许可证与溯源问题的答案,并把「做/不做」(go/no-go)的结论加入沙箱 Note 的延后阶段计划。 +- 在任何 Windows 层级实现开始之前,先有一份评估记录下探测、方言、许可证与溯源问题的答案,并把「采用/不采用」(go/no-go)结论加入沙箱 Agent Note 的延后阶段计划。 ## 风险 - 处于安全关键位置的单一维护者供应链——这正是本提案定为一道评估门禁、而非采用决定的原因。 -- 该包尚且年轻;在 Windows 阶段启动之前其 API 与打包方式可能反复变动,届时需对照线上注册表重新核验。 +- 该包尚且年轻;在 Windows 阶段启动之前其 API 与打包方式可能反复变动,届时需对照届时的在线注册表重新核验。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index 6685685e24..e252ed8190 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md 2026-06-20-assembled-assistant-messages-only.md: ba8135a3d63f292cfedd23de8b4b9d43b4455e8c -2026-06-20-assembled-assistant-messages-only.zh.md: 9a42a202425158edd85d7a3f2ef4b0b97e00da90 +2026-06-20-assembled-assistant-messages-only.zh.md: 5d29655a0697d05f20e491d28272898a08ec3e0b diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index 9a42a20242..5d29655a06 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,20 +1,20 @@ # Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片 -Status: rejected — 高保真分片回放、部分失败流与快照回放目前依赖持久化的 `assistant/chunk` 事件。只有具备不丢失信息的回放/产物替代方案后,才能删除分片。 +Status: rejected — 高保真分片回放、失败流的部分输出与快照回放目前依赖持久化的 `assistant/chunk` 事件。只有具备无信息损失的回放或产物替代方案后,才能删除分片。 [English](2026-06-20-assembled-assistant-messages-only.md) | 中文 ## 问题 -当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 Agent Note(agent 决策记录)](../../implemented/architecture/2026-06-14-session-persistence.md)选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据,快照场景通过对分片事件分组来回放模型,ACP(Agent Client Protocol)加载时从分片重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 +当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 Agent Note](../../implemented/architecture/2026-06-14-session-persistence.md)选择这一方案是为了 token 级回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的增量记录占据,快照场景通过对分片事件分组来回放模型,ACP(Agent Client Protocol)加载时从分片重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级追踪。 对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需分片即已具备;分片是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以分片形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 ## 提案 -停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个刻意设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要部分失败流输出的场景必须在回放 fixture 中记录该输出。 +停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个明确设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要失败流部分输出的场景必须在回放 fixture 中记录该输出。 -ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的提供方历史恢复运行。 +ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并基于有效的提供方历史继续运行。 ## 验收标准 @@ -22,7 +22,7 @@ ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回 - [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)不再要求逐字存储每个流式分片。 - `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型分片。 - `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。 -- 存储的日志大幅缩小,且在没有分片缺口的情况下保持 `seq` 连续。 +- 存储的日志大幅缩小,且删除分片后仍保持 `seq` 连续,不留下序号缺口。 - 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。 ## 放弃了什么 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index e09db8fbbf..5a290c7b73 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md 2026-06-20-drop-bash-output-spill-files.md: b2bd1a04ee1524bab29814ffa7c22712a83ee5f7 -2026-06-20-drop-bash-output-spill-files.zh.md: c1b5670fac28a90cc4eb229ba0013e39067af8eb +2026-06-20-drop-bash-output-spill-files.zh.md: ce12eb919c728d856926d6b2441abc26548b615e diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index c1b5670fac..ce12eb919c 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,31 +1,31 @@ -# Agent Note: 移除 bash 完整输出溢出文件 +# Agent Note: 移除 bash 完整输出 spill 文件 -Status: rejected — 完整输出恢复是真实的 bash 行为。未来的产物/blob 服务或许能将其泛化,但在替代方案就位前删除溢出文件会丢失有用的命令输出。 +Status: rejected — 完整输出恢复是真实的 bash 行为。未来的产物/blob 服务或许能将其泛化,但在替代方案就位前删除 spill 文件会丢失有用的命令输出。 [English](2026-06-20-drop-bash-output-spill-files.md) | 中文 ## 问题 -`dsh-bash-local` 在内存中保留有界的输出,并将大体量的 stdout/stderr 流溢出到私有临时文件。这要求一个私有目录、仅所有者可写的随机文件创建、关闭失败处理、基于字节偏移的增量读取、有损读取报告、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,该工具会告知模型去读取一个本地溢出路径。 +`dsh-bash-local` 在内存中保留有界的输出,并将大体量的 stdout/stderr 流写入私有临时 spill 文件。这要求一个私有目录、随机创建仅所有者可访问的文件、关闭失败处理、基于字节偏移的增量读取、有损读取报告、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,该工具会告知模型去读取一个本地 spill 路径。 -这解决了一个真实问题,但方式狭隘且有泄漏。溢出路径是一个暴露在模型输出中的进程级文件系统产物,而非具有作用域访问控制、保留策略或 UI 支持的持久化 harness 产物。它还使后台任务的读取变得复杂,因为有损增量读取必须指向一个或两个溢出文件。 +这解决了一个真实问题,但方式狭隘且有泄漏。spill 路径是一项暴露给模型的进程本地文件系统产物,而非具有作用域访问控制、保留策略或 UI 支持的持久化 harness 产物。它还使后台任务的读取变得复杂,因为有损增量读取必须指向一个或两个 spill 文件。 ## 提案 -保留尾部截断,移除完整输出溢出文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具有明确的所有权、清理和 UI 渲染),然后让 bash 将大体量输出附加到该服务。 +保留尾部截断,移除完整输出 spill 文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具有明确的所有权、清理和 UI 渲染),然后让 bash 将大体量输出附加到该服务。 -本提案可以独立于[通用长时间运行工具运行时](../../implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再提供溢出路径。 +本提案可以独立于[通用长时间运行工具运行时](../../implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再提供 spill 路径。 ## 验收标准 -- `CollectedOutput` 不再携带溢出路径。 +- `CollectedOutput` 不再携带 spill 路径。 - `OutputCollector` 仅保留有界缓冲区,删除临时文件机制。 - `renderResult()` 报告截断时不包含文件系统路径。 - 测试覆盖尾部截断,不再断言完整输出文件的内容。 -- [docs/defensive-patterns.md](../../../../docs/defensive-patterns.md) 中的安全指导不再将私有溢出文件视为面向模型的接口。 +- [docs/defensive-patterns.md](../../../../docs/defensive-patterns.md) 中的安全指导不再将私有 spill 文件视为面向模型的接口。 ## 放弃的能力 -模型或用户无法再从临时文件恢复大体量命令输出中被省略的前缀。在真正的产物服务出现之前,这是可以接受的。当前的溢出路径为一个生命周期和权限均未经设计的功能引入了过多的定制机制。 +模型或用户无法再从临时文件恢复大体量命令输出中被省略的前缀。在真正的产物服务出现之前,这是可以接受的。当前的 spill 路径为一个生命周期和权限均未经设计的功能引入了过多的专用机制。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index 698d5a5ad6..8a90d8e997 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md 2026-06-20-drop-durable-step-boundaries.md: c5c4f269a378e334c4dc509d1288146d77d9a520 -2026-06-20-drop-durable-step-boundaries.zh.md: f2150699c74b16557d936d6833fcba02e7d76e69 +2026-06-20-drop-durable-step-boundaries.zh.md: 66e6d02225eb51bd395e84114d54a0dac8389fe0 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index f2150699c7..66e6d02225 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,28 +1,28 @@ # Agent Note: 移除持久化的步骤边界事件 -Status: rejected — `step/end` 是模型步骤已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的步骤作用域事件推断完成状态更便于理解崩溃修复、不变式与 transcript(文本记录)检查。 +Status: rejected — `step/end` 是模型步骤已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的步骤级事件推断完成状态更便于理解崩溃修复、不变式与 transcript(文本记录)检查。 [English](2026-06-20-drop-durable-step-boundaries.md) | 中文 ## 问题 -会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤作用域的事件本身已经携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层面也忽略它们,主要消费方是不变式检查、测试、快照预期输出和崩溃恢复。 +会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤级事件本身已经携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层面也忽略它们,主要消费方是不变式检查、测试、快照预期输出和崩溃恢复。 -被否决的论点是:边界事件使日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导状态,就能判断一次模型请求是已完成、已崩溃还是正在修复。同样,一个孤立的 `step/start` 对于「模型请求已发起但在产生任何分片之前就失败了」的场景也有价值。 +被否决的论点是:边界事件使日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导状态,就能判断一次模型请求是已完成、已崩溃还是正在修复。同样,单独一条 `step/start` 对于「模型请求已发起但在产生任何分片之前就失败了」的场景也有价值。 ## 提案 -将轮次作为唯一的持久化边界。`step/start` 和 `step/end` 将从 `SessionEventMap` 中移除;在需要分组的事件上保留数值型 `step` 字段。agent loop(智能体循环)递增步骤计数器并以该编号记录步骤作用域的事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 +将轮次作为唯一的持久化边界。`step/start` 和 `step/end` 将从 `SessionEventMap` 中移除;在需要分组的事件上保留数值型 `step` 字段。agent loop(智能体循环)递增步骤计数器并以该编号记录步骤级事件,但不再追加开始与结束边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 -不变式插件应当强制步骤作用域的事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求独立的边界记录包围它们。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,修复路径仍然可以关闭该轮次而无需捏造步骤边界记录。 +不变式插件应当强制步骤级事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求独立的边界记录包围它们。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,修复路径仍然可以关闭该轮次而无需捏造步骤边界记录。 ## 验收标准 - `SessionEventMap` 不再包含 `step/start` 或 `step/end`。 - agent loop 中不再有 `closeStep()` 终结路径。 - ACP 快照和持久化契约 fixture(测试前置数据)不再期望步骤边界行。 -- `deriveMessages()` 和回放从步骤作用域的事件推导出相同的消息历史。 -- [事件分类体系文档](../../../../docs/architecture.md)将轮次描述为持久化边界,将步骤描述为步骤作用域记录上的一个字段。 +- `deriveMessages()` 和回放从步骤级事件推导出相同的消息历史。 +- [事件分类体系文档](../../../../docs/architecture.md)将轮次描述为持久化边界,将步骤描述为步骤级记录上的一个字段。 - 会话格式版本和已记录的 fixture 被刷新;按预发布格式策略,非当前版本的已存储日志被拒绝。 ## 放弃了什么 diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index 5f3e15ee98..0ae596bf03 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md 2026-06-20-fold-session-persistence-interface.md: 895b868b2a80d8655284bae1364a85e19e174da7 -2026-06-20-fold-session-persistence-interface.zh.md: c124b16531f904eb72cb8ac3842642d819309e14 +2026-06-20-fold-session-persistence-interface.zh.md: d5ce0ed4532706ffadb43460cd0974507393a5ed diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index c124b16531..d5ce0ed453 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -1,14 +1,14 @@ # Agent Note: 将持久化接口合并进 dsh-session -Status: rejected — 独立的持久化接口包是为持久后端设计的模块化能力 seam。将其折叠进 `dsh-session` 虽能减少包数量,却会牺牲更清晰的后端边界。 +Status: rejected — 独立的持久化接口包是为持久化后端设计的模块化能力 seam。将其折叠进 `dsh-session` 虽能减少包数量,却会牺牲更清晰的后端边界。 [English](2026-06-20-fold-session-persistence-interface.md) | 中文 ## 问题 -`dsh-session-persistence` 是一个接口包(package),其核心概念已经由 `dsh-session` 拥有:`SessionHeader`、`SessionEvent`、`SessionId`、`session/event` 与 `session/flush`。该包额外添加了抽象的 `SessionPersistence` 服务、共享写入协调器和契约辅助工具。后端包依赖它,`agent-loop`(智能体循环)也需要可选地查找一个同级服务来实现恢复。 +`dsh-session-persistence` 是一个接口包,其核心概念已经由 `dsh-session` 拥有:`SessionHeader`、`SessionEvent`、`SessionId`、`session/event` 与 `session/flush`。该包额外添加了抽象的 `SessionPersistence` 服务、共享写入协调器和契约辅助工具。后端包依赖它,为实现恢复,`agent-loop`(智能体循环)还需要按需查找这个同级服务。 -当持久化还是一个全新的可替换后端设计时,能力 seam 的拆分是合理的。但在可变摘要被移除之后,这个接口包基本上只是包装了会话日志自身的存储关切。继续保持独立可能带来的仪式感多于清晰度。 +当持久化还是一个全新的可替换后端设计时,能力 seam 的拆分是合理的。但在可变摘要被移除之后,这个接口包基本上只是包装了会话日志自身的存储职责。继续保持独立可能带来的仪式感多于清晰度。 ## 提案 @@ -26,6 +26,6 @@ Status: rejected — 独立的持久化接口包是为持久后端设计的模 ## 放弃了什么 -`dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是代价。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段,在尚无外部消费方时,多出的包看起来更像是过早的抽象。 +`dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是代价。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段尚无外部消费方时,这个额外的包更像是过早引入的抽象。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 704d2e8227..09f42a9047 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.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/rejected/simplification/2026-06-20-truncate-interrupted-turns.md 2026-06-20-truncate-interrupted-turns.md: 3c7acf8d673568f851edd52635a28f73d9bf5f6f -2026-06-20-truncate-interrupted-turns.zh.md: 36a3d3f3cbd736c8f9a70fea453a314c0495d6f2 +2026-06-20-truncate-interrupted-turns.zh.md: f348e6a99d8214f7968d9f57931f9dddd37973be diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index 36a3d3f3cb..f348e6a99d 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -8,11 +8,11 @@ Status: rejected — 单个轮次可以包含大量真实工作,包括多个 当前的持久化契约会保留已持久写入但从未关闭的最终轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成 error `tool/result` 事件,在步骤处于打开状态时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这次修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径进行了建模。 -这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使提供方历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化尾部保留。 +这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使提供方历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就以最大限度保留尾部为优化目标。 ## 提案 -加载时只保留最后一个已完成的轮次。后端仍然容忍并截断撕裂的最终记录,但如果解析出的持久前缀止于一个打开的 `turn/start` 之后,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不引入 `interrupted` 轮次结束原因。 +加载时只保留最后一个已完成的轮次。后端仍然容忍并截断撕裂的最终记录,但如果解析出的持久前缀在 `turn/start` 之后仍有轮次未关闭,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不引入 `interrupted` 轮次结束原因。 这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次提示词从最后一个已知合法的提供方 transcript(文本记录)恢复,而不是从部分重建的最终轮次恢复。 @@ -20,7 +20,7 @@ Status: rejected — 单个轮次可以包含大量真实工作,包括多个 - `TurnEndReasonMap` 移除 `interrupted` 变体。 - `interruptedTurnClosers()` 及其测试删除。 -- 持久化协调器的修复钩子截断后端特有的撕裂/打开尾部状态,不追加关闭事件。 +- 持久化协调器的修复钩子截断后端特有的撕裂或未关闭的尾部状态,不追加关闭事件。 - [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)说明加载返回最后一个已完成的轮次,不包含部分最终轮次。 - 快照与契约测试随其所固定的行为一同更新。 - 会话格式版本与记录的 fixture(测试前置数据)刷新;按预发布格式策略,非当前版本的存储日志被拒绝,不提供迁移路径。 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index efd4492ede..ea631fa09c 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md 2026-07-04-prune-unimplemented-subagent-vocabulary.md: 276e832af695acbcf70103def8b51fb8c6e1033f -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 1cb835ff26e407223646d1c92a78c7fc42c9e564 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 81b79c77a055f97785c6a96b7b17802878ded623 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index 1cb835ff26..81b79c77a0 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -11,23 +11,23 @@ Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`tool - **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 - **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 -在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三个后续 subagent 工作流(按会话快照回放、fork seed 边界、ACP(Agent Client Protocol)后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 +在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三项后续 subagent 工作(按会话快照回放、fork seed 边界、ACP(Agent Client Protocol)后端)都围绕这块接口面落地,却连一个消费方都没有产生。 ## 提案 -从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、脚本化 fixture 的 structured 分支和能力旋钮,以及为固定被移除接口面而存在的测试。`dsh-tools` 的 peer/dev 依赖应从 `packages/subagent/subagent/package.json` 中删除。更新 [subagent.md](../../../../docs/core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及受影响的提供方 README。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam Agent Note(agent 决策记录)的能力目录。 +从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、脚本化 fixture 的 structured 分支和能力旋钮,以及为固定被移除接口面而存在的测试。`dsh-tools` 的对等依赖(peer dependency)和开发依赖应从 `packages/subagent/subagent/package.json` 中删除。更新 [subagent.md](../../../../docs/core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及受影响的提供方 README。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam Agent Note 的能力目录。 **保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash 执行器中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 -这是[从持久化 seam 裁剪死方法](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须为无人声明的成员,甚至更弱,因为这里连一个实现都没有。 +这是[从持久化 seam 裁剪死方法](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须声明、却无人使用的成员,甚至更弱,因为这里连一个实现都没有。 ## 曾考虑的替代方案 ### 为什么不保留? -两类能力的设计是 seam Agent Note 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 Agent Note 作为记录仍然成立;而且 seam Agent Note 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此基于真实实现提供方重新添加时,将固定出一份比当前推测性契约更好的契约。 +两类能力的设计是 seam Agent Note 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 Agent Note 作为记录仍然成立;而且 seam Agent Note 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此在由真实提供方实现并重新添加时,将确定一份比当前推测性契约更好的契约。 ## 验收标准 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 1b4e9c4b54..c62b690b4e 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md 2026-07-12-collapse-workflow-to-foreground-core.md: 629e2140523c3ae7caf533de99821206d05f1b8e -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 3ae5e026a0b123a6b695b339010bf14a99515912 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 1af80e7ca28eb0e5acf3f642244260034c57d0ab diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 3ae5e026a0..1af80e7ca2 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,11 +6,11 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体) outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 +工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 -live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 +这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 @@ -20,7 +20,7 @@ live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.i 保留已使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose(资源释放)、结构化结果、worker 隔离与前台工具收集。移除所有 `workflow/*` 事件及其仅供事件使用的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观测者;将工作流元数据收缩为工具实际使用的 name;移除仅供事件使用的 run id/meta 快照与合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从其 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 -修订已实施的动态工作流 Agent Note(agent 决策记录),并更新 seam/工具/worker README、工具 schema、生成的 catalog 与包(package)依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 +修订已实施的动态工作流 Agent Note,并更新 seam/工具/worker README、工具 schema、生成的 catalog 与包依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 ## 曾考虑的替代方案 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index 3b97a7fb1e..306226eb1f 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md 2026-07-12-prune-unused-skill-registry-surface.md: 5a13effa04a6cd9954741a0a33ebc6fc3512fab8 -2026-07-12-prune-unused-skill-registry-surface.zh.md: 46d49a02c294c492abdd6e7e611a5c92eb317c12 +2026-07-12-prune-unused-skill-registry-surface.zh.md: f6010cba4efd3c80cab867dfdf2470ccf1495d6a diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index 46d49a02c2..f6010cba4e 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 裁剪 skill(技能)注册表中未使用的接口 +# Agent Note: 裁剪 skill 注册表中未使用的接口 Status: rejected — 直接在运行时注册 skill 是为第三方插件保留的有意扩展路径。 @@ -6,21 +6,21 @@ Status: rejected — 直接在运行时注册 skill 是为第三方插件保留 ## 问题 -skill 服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)器以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 +skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)函数以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 ## 提案 -移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现 epoch,但已完成的目录缓存仅以 cwd 为键:每次提供方变更同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和本地提供方副本中移除 `whenToUse`、`SkillCandidate.path` 与 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,因为它们要么是有意开放的扩展词汇,要么是生产消费的字段。 +移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中发现操作的 epoch,但已完成的目录缓存仅以 cwd 为键:每次提供方变更同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和本地提供方副本中移除 `whenToUse`、`SkillCandidate.path` 与 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,因为它们要么是有意开放的扩展词汇,要么是生产消费的字段。 -同步修订 skill 系统 Agent Note(agent 决策记录)、README、JSDoc、目录文件与测试。agent(智能体)作用域的系统提示词段、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明它们未被使用。 +同步修订 skill 系统 Agent Note、README、JSDoc、目录文件与测试。agent(智能体)作用域的系统提示词段、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明它们未被使用。 ## 曾考虑的替代方案 -**保留面向嵌入方的运行时 skill 注册。** 这是已实现的 skill Agent Note 中有意提供的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份,并接受提供方的重复语义。本提案选择只保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效与查找路径。 +**保留面向嵌入方的运行时 skill 注册。** 这是已实现的 skill Agent Note 中有意提供的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份,并接受提供方处理重复项的语义。本提案选择只保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效与查找路径。 ## 验收标准 -- skill 收集只有一条提供方驱动的路径,已完成缓存仅以 cwd 为键,revision epoch 仅用于进行中的失效检测;保留的 skill 字段要么有生产读取方,要么有记录在案的有意扩展契约。 +- skill 收集只有一条提供方驱动的路径,已完成缓存仅以 cwd 为键,revision epoch 仅用于使进行中的发现操作失效;保留的 skill 字段要么有生产读取方,要么有记录在案的有意扩展契约。 - agent 作用域的提示词段、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的 structured-output 提交行为保持不变。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 diff --git a/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml index 98acc791c3..71171bbd04 100644 --- a/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.md 2026-07-19-fold-compaction-package-split.md: 47c9feb6bb0dd06fec0f002b7c1e930b288abe5e -2026-07-19-fold-compaction-package-split.zh.md: 53717ff10d1210bd2072f322d1936ac6c389afcd +2026-07-19-fold-compaction-package-split.zh.md: ef2d1ee34783477f1b77f75d05b58d7679c6c4d4 diff --git a/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md b/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md index 53717ff10d..ef2d1ee347 100644 --- a/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md @@ -8,7 +8,7 @@ Status: rejected — 计划增加更多压缩后端,因此接口包与 basic 压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 -该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 +该拆分增加了一份包 manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有实际的后端替换用例。[能力 seam 决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 ## 提案 @@ -22,15 +22,15 @@ Status: rejected — 计划增加更多压缩后端,因此接口包与 basic **为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 -**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 +**将实现包名用于接口包。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 ## 验收标准 - 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 - `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 -- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 +- 现有部署可以使用等效配置加载保留的包,模型可见行为等效。 - 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 -- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 +- loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 ## 风险 diff --git a/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.i18n.yaml index c13545596e..9b5147296a 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md 2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md: 475fd632cd4f75c966d4693e049edd48a1301992 -2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md: 47b20fdb237ab52aecba6b7df20dbd25eeb1649e +2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md: dfd7cdb3d4a6c5231f75db6c422a32bc6b7f080a diff --git a/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md b/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md index 47b20fdb23..dfd7cdb3d4 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md @@ -6,17 +6,17 @@ Status: rejected — 实现(PR #679)证伪了行为等价前提:vitest 的 ## 问题 -三个包(package)手写了 promise 包装的定时器,而 `node:timers/promises` 内置模块早已提供同等能力;其他包(`dsh-llm-mock-server` 的 `pause()`、`dsh-lsp-local`、`dsh-acp-snapshot`)已经在使用该内置模块,因此这些手写副本同时也是一处一致性缺口: +三个包手写了用 promise 包装的定时器,而 `node:timers/promises` 内置模块早已提供同等能力;其他包(`dsh-llm-mock-server` 的 `pause()`、`dsh-lsp-local`、`dsh-acp-snapshot`)已经在使用该内置模块,因此这些手写副本同时也是一处一致性缺口: -- `packages/llm/llm-retry/src/index.ts` 的 `cancellableDelay()`(约 14 行):`new Promise` + `setTimeout` + 手动添加/移除 abort 监听器,计时走完时 resolve 为 `true`、被中止时 resolve 为 `false`,仅在退避等待处消费一次。 +- `packages/llm/llm-retry/src/index.ts` 的 `cancellableDelay()`(约 14 行):`new Promise` + `setTimeout` + 手动添加和移除中止监听器,定时器触发时 resolve 为 `true`、被中止时 resolve 为 `false`,仅在退避等待处消费一次。 - `packages/workflow/workflow-workerthread/src/host.ts` 的 `sleep()`(约 7 行):promise 包装、已 unref 的 `setTimeout`,用作 dispose(资源释放)宽限的时间上界。 -- `packages/pty/pty-local/src/session.ts` 的 `delay()`(约 4 行):朴素的 promise 包装 `setTimeout`,用于轮询与拆除等待。 +- `packages/pty/pty-local/src/session.ts` 的 `delay()`(约 4 行):朴素的 promise 包装 `setTimeout`,用于轮询与拆卸等待。 ## 提案 用 `import { setTimeout } from 'node:timers/promises'` 替换这三处实现: -- llm-retry:`try { await setTimeout(delayMs, undefined, { signal }); /* retry */ } catch { /* abort → fail */ }`。传入 signal 后,该 promise 只会以 abort 错误拒绝,已提前中止的 signal 则立即拒绝;行为完全一致,包括中止时清除定时器。按仓库的空 catch 规则,这个空 `catch` 注明其吞下的是 abort 拒绝。 +- llm-retry:`try { await setTimeout(delayMs, undefined, { signal }); /* retry */ } catch { /* abort → fail */ }`。传入 signal 后,该 promise 只会因中止错误而拒绝,已提前中止的 signal 则立即拒绝;行为完全一致,包括中止时清除定时器。按仓库的空 catch 规则,这个空 `catch` 注明其吞下的是 abort 拒绝。 - workflow-workerthread:`setTimeout(ms, undefined, { ref: false })`,语义完全等价,包括不会让事件循环保持存活。 - pty-local:`import { setTimeout as delay } from 'node:timers/promises'`,签名完全相同,调用点无需改动。 diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 202b4bcfd8..020768c200 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-doc-standards -description: 'Use when writing, moving, reviewing, or auditing documentation in the deepseek-harness repo — choosing where content belongs, trimming doc slop, responding to a verify-doc-budgets gate failure, or requests like "improve the docs", "audit the docs for slop", "where should this be documented", "this doc is too long".' +description: 'Use when writing, moving, reviewing, or auditing documentation in the deepseek-harness repo — choosing hierarchy and detail, separating tutorials from references, checking tutorial progression, trimming doc slop, responding to a verify-doc-budgets failure, or requests like "improve the docs", "audit the docs", "where should this be documented", or "this doc is too long".' --- # Applying the DeepSeek Harness Documentation Standard @@ -9,24 +9,32 @@ The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow c ## Sources of truth (read, don't re-summarize) -- [docs/AGENTS.md](../../../docs/AGENTS.md) — the taxonomy ("one home per fact"), budgets, slop checklist. +- [docs/AGENTS.md](../../../docs/AGENTS.md) — hierarchy, tutorial/reference forms, taxonomy, budgets, and slop checklist. - [.agents/notes/README.md](../../notes/README.md) — when a decision earns an Agent Note, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-agent-note-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem. - [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change. - Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects. - [Archived Agent Notes](../../notes/archived/AGENTS.md) — frozen historical snapshots excluded from editorial maintenance and evolving documentation gates. -## Placing content +## Review structure before prose -Run the placement test in the standard's taxonomy table, then check the constraints that make a placement expensive or wrong: +Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve the chronological evidence required by its contract without treating chronology as a teaching sequence. + +1. Locate the document in the repository and navigation trees. State its own subject and identify its direct children. +2. Set the detail boundary. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject. +3. Classify the document from its intended use, not its path or title. A tutorial must lead through ordered work to an observable outcome; a reference must support lookup within an explicit scope without requiring sequential reading. +4. For a tutorial, privately classify the starting reader and concepts as beginner, intermediate, or advanced. Trace each concept to its prerequisites, reorder premature material, and move optional advanced detail to a later tutorial or reference. +5. Split substantial mixed forms. Keep a small secondary form only behind a clear structural boundary. + +Then check constraints that make placement expensive or wrong: - Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn. - Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source. - Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown links, `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments, but nothing catches heading-anchor fragments — grep `#the-heading` across the repo yourself (one anchor is hardcoded in `scripts/gen-cordis-catalog.ts`). - A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change. -## Auditing the corpus +## Audit the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. +After the structural pass, hunt the standard's slop checklist with the cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and audit prose introduced by the new base. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. 2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. diff --git a/AGENTS.md b/AGENTS.md index e03b128876..a42f39084a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/ core/ product API spine: session, system-prompt, tools, agent, agent-loop typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) - bash/ bash executor seam + local impl + model-facing bash tools + bash/ bash executor seam + local/pwsh impls + model-facing shell tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/README.i18n.yaml b/README.i18n.yaml index f5eb3be571..e705f07877 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 7777c35a2785856e553cb963920288ff8021dc27 -README.zh.md: 85ee977063e4d822d71e1e780ab4a461260aaf53 +README.md: b8e46044fb8857730b32d9fbbb9ed4de964d6017 +README.zh.md: e289d523bf61a577f1dd2335b3b4567736d9100d diff --git a/README.md b/README.md index 7777c35a27..b8e46044fb 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,9 @@ It uses an architecture where **everything is a plugin**. ## Internal testing notice -感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 +DeepSeek Harness is under internal testing. Features and interfaces may change. -“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。 - -为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 +The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group. ## Install @@ -26,7 +24,7 @@ scripts/install.sh The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI. -The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. +The default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options. ## Use DeepSeek Harness @@ -84,11 +82,6 @@ Follow <a href="https://x.com/Deepseekharness">DeepSeek Harness on Twitter</a> f ## Development -```sh -pnpm install -pnpm run test:coverage -``` - Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages. For agents, follow [AGENTS.md](AGENTS.md). diff --git a/README.zh.md b/README.zh.md index 85ee977063..e289d523bf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -8,11 +8,9 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 ## 内测声明 -感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 +DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。 -“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。 - -为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 +为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。 ## 安装 @@ -26,7 +24,7 @@ scripts/install.sh 安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。 -安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 +默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。 ## 使用 DeepSeek Harness @@ -88,11 +86,6 @@ pnpm run demo:acp ## 开发 -```sh -pnpm install -pnpm run test:coverage -``` - 请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..077de09a20 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI/TUI, the Web UI, and the Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -46,6 +46,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -62,7 +63,14 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | +| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | +| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | +| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | @@ -79,6 +87,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`ws`](https://github.com/websockets/ws) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -109,6 +118,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | @@ -122,6 +132,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | | [`knip`](https://github.com/webpro-nl/knip) | ISC | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index fd317e355d..e115e43e64 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca -README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01 +README.md: ce7af5a299e45d6f107686aff043246914dce8ed +README.zh.md: e97fec9d6bb726cb1e419a1ca2fa1871d4d203ca diff --git a/apps/cli/README.md b/apps/cli/README.md index 6fdca68eed..ce7af5a299 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,75 +2,24 @@ English | [中文](README.zh.md) -The `dsh` command has three entry modes: a required raw config overlay, a one-shot headless prompt, and the Web UI. [`src/args.ts`](src/args.ts) owns the Commander grammar, and [`src/bin.ts`](src/bin.ts) dynamically imports only the selected runner. Unknown commands and leaked options fail with a nonzero exit code. +The `dsh` command is the product launcher for raw Cordis configurations, the Web UI, and one-shot headless tasks. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. + +## Entry modes + +| Command | Purpose | +|---|---| +| `dsh --config ./app.cordis.yml` | Run an explicit patch-list configuration over the shipped base. | +| `dsh web` | Start the browser UI with the shipped Web composition and optional personal configuration. | +| `dsh -p "task"` | Run one fresh persisted session, print the final answer, and exit. | + +The invoking directory is the default workspace root. Web and headless share the shipped provider, persistence, policy, tool, repository Plugin, and telemetry composition; raw config selects its own deployment-specific front door. ## Raw config -Raw `dsh` requires an explicit patch-list config: +Raw `dsh` requires `--config`. The named patch list is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml); it is not a complete replacement tree and does not add a surface overlay or personal `$DSH_HOME/config.yaml`. Use `--dump-default-config` and `--dump-config` to inspect the resulting tree without booting it. -```sh -dsh --config ./app.cordis.yml -``` +The [CLI behavior reference](reference/README.md) owns exact overlay precedence, flags, shutdown behavior, deployment defaults, and the source launcher. -The named file is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +## Development -A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve: - -```yaml -- id: agent-loop - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -Inspect the effective tree without booting it: - -```sh -dsh --dump-default-config -dsh --config ./app.cordis.yml --dump-config -``` - -`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## Web and headless - -`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. - -```sh -dsh web -dsh web --config ./web-profile.cordis.yml -dsh web --dump-default-config -dsh web --dump-config -``` - -The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. - -`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags. - -Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. - -Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution. - -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. - -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. - -## Shared deployment behavior - -The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it. - -Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. - -The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. - -## Source launcher - -Link the source-running launcher onto PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. +Production Web and headless runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index d8d7122729..e97fec9d6b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,75 +2,24 @@ [English](README.md) | 中文 -`dsh` 命令有三种入口模式:必需的原始配置 overlay、一次性 headless 提示词,以及 Web UI。[`src/args.ts`](src/args.ts) 拥有 Commander 命令行语法,[`src/bin.ts`](src/bin.ts) 只会动态导入选中模式的运行器。未知命令和误传入其他模式的选项都会以非零代码退出。 +`dsh` 命令是原始 Cordis 配置、Web UI 和一次性无头任务的产品启动器。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 + +## 入口模式 + +| 命令 | 用途 | +|---|---| +| `dsh --config ./app.cordis.yml` | 在随附基础配置之上运行显式 patch 列表配置。 | +| `dsh web` | 使用随附 Web 组合和可选个人配置启动浏览器 UI。 | +| `dsh -p "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | + +调用目录是默认 workspace 根目录。Web 与无头模式共享随附的提供方、持久化、策略、工具、repository Plugin 和遥测组合;原始配置自行选择部署专用前端入口。 ## 原始配置 -原始 `dsh` 要求显式传入一份 patch 列表配置: +原始 `dsh` 必须提供 `--config`。指定的 patch 列表直接应用到 [`config/base.cordis.yml`](config/base.cordis.yml) 之上;它不是完整替代树,也不会添加 surface overlay 或个人 `$DSH_HOME/config.yaml`。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查生成的配置树。 -```sh -dsh --config ./app.cordis.yml -``` +[CLI(命令行界面)行为参考](reference/README.md)负责确切的 overlay 优先级、flag、关闭行为、部署默认值和源码启动器。 -指定文件会通过 Include 插件的 patch 算法,直接应用在 [`config/base.cordis.yml`](config/base.cordis.yml) 之上。它不是完整替换树,系统也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。base 有意不包含启动 agent(智能体)或交互入口;必需的 overlay 负责选择这些部署细节。相对配置路径以调用目录为基准解析。配置解析、schema 校验、模块解析或插件启动失败都会被报告,并以非零代码退出。SIGINT 和 SIGTERM 会在退出前 dispose(资源释放)已挂载的根上下文。 +## 开发 -patch 通过 `id` 定位 base 配置项,并替换该配置项的完整 `config` 值,而不是深度合并各个键。它也可以插入新配置项: - -```yaml -- id: agent-loop - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -可以在不启动应用的情况下检查有效配置树: - -```sh -dsh --dump-default-config -dsh --config ./app.cordis.yml --dump-config -``` - -`--dump-default-config` 只打印随附 base。`--dump-config` 要求提供 `--config`,并打印带来源注释的 base 与 overlay。组合过程使用 `@cordisjs/plugin-include` 的 `applyEntryPatches` 和 `entryListSchema`;`!!js` 表达式保持未求值状态,未匹配的 patch 目标会报告到 stderr。 - -## Web 与 headless - -`dsh web` 会启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续应用该文件。`dsh web --config <path>` 会以显式 patch 列表替换个人层。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会转为 Web 宿主 patch;各自所属插件的 schema 会在启动时校验它们。`--dev` 会挂载客户端插件 HMR(热模块替换)接收器,要实现无需刷新的客户端 bundle 更新,还需单独运行 `pnpm run dev:web` watcher。 - -```sh -dsh web -dsh web --config ./web-profile.cordis.yml -dsh web --dump-default-config -dsh web --dump-config -``` - -生产 Web 运行器需要已构建的包(package)与前端产物(`pnpm run build`)。它默认通过 `http://127.0.0.1:3080` 提供服务。绑定所有网络接口时,系统也会信任本机探测到的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任边界所接受的具名权威。 - -`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。 - -Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。 - -两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。 - -新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。 - -`DSH_TOOLS_MODE` 为 Web/headless 进程选择 `native`、`code` 或 `both`;其他值会在启动时失败。[`config/core-web.cordis.yml`](config/core-web.cordis.yml) 是可选的 Web overlay,它在保留随附宿主、浏览器、workspace、持久化与权限组合的同时,将面向原生模型的工具缩减为持久 `bash` 和 `str_replace_editor`。 - -## 共享部署行为 - -base 会挂载原生 DeepSeek 适配器、设置与凭据提供方、稳定的 `web_search`、仓库插件支持与会话遥测。提供方凭据位于 `$DSH_HOME/.env` 或环境中,且仍可轮换,因为启动器绝不会把凭据文件提升进 `process.env`。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;除非 overlay 插入提供方并启用 `web_fetch`,否则后者处于禁用状态。 - -会话事件默认以 OTLP/HTTP 日志的形式流式发送。`DSH_TELEMETRY_OTLP_URL` 用于选择其他 collector。`DSH_TELEMETRY_DISABLED` 的任何非空值都会在启动前禁用遥测配置项。随附 base 没有遥测脱敏规则,因此导出记录可能包含消息文本、工具参数与结果,以及 workspace 路径;该部署决策由[遥测 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) 统一定义。 - -空的 `repository-plugins` 配置项允许 Web/headless 个人配置与原始 overlay 挂载已准备的不可变仓库插件 generation。详见[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI(命令行界面)还将 `@deepseek-ai/dsh-mcp-client` 作为 overlay 依赖发布,但默认不启用任何 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。 - -## 源码启动器 - -将以源码运行的启动器链接到 PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -它会通过自身实际路径解析该检出,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定指向检出根目录,因此 workspace 包解析不受调用目录影响。`pnpm run dsh` 使用同一入口并转发参数。构建后的形式是执行 `pnpm run build` 后的 `apps/cli/lib/bin.js`。 +生产环境的 Web 和无头运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0bede25716..28f58bcf4d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -54,6 +54,8 @@ flowchart LR cfg --> plugin_dsh_base_approval plugin_dsh_base_permission["permission<br/>@deepseek-ai/dsh-permission"] cfg --> plugin_dsh_base_permission + plugin_dsh_base_bash_env["bash-env<br/>@deepseek-ai/dsh-bash-env"] + cfg --> plugin_dsh_base_bash_env plugin_dsh_base_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"] cfg --> plugin_dsh_base_tool_bash plugin_dsh_base_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"] @@ -171,6 +173,7 @@ flowchart LR | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | +| `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 26f59fe46c..dddf2fc1b5 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -173,6 +173,9 @@ sandbox: danger-full-access approval: never +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ccf4b2197..4ba1da7e86 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,9 +8,8 @@ "dsh": "lib/bin.js" }, "files": [ - "lib/bin.js", - "config", - "src" + "lib/*.js", + "config" ], "license": "BSD-3-Clause", "dependencies": { @@ -22,6 +21,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -115,6 +116,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", @@ -131,7 +133,8 @@ "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "node-addon-require-builtin": "^0.1.4" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml new file mode 100644 index 0000000000..7e5b8e5c58 --- /dev/null +++ b/apps/cli/reference/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/cli/reference/README.md +README.md: b37ec9ed61ea4e9899a51316065d4188f30997ad +README.zh.md: ca29808a6c8e670f0d0b82c59b1a2c1fa0e13565 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md new file mode 100644 index 0000000000..b37ec9ed61 --- /dev/null +++ b/apps/cli/reference/README.md @@ -0,0 +1,76 @@ +# `dsh` CLI behavior reference + +English | [中文](README.zh.md) + +This reference defines the raw-config, Web, and headless command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. + +## Raw config + +Raw `dsh` requires an explicit patch-list config: + +```sh +dsh --config ./app.cordis.yml +``` + +The named file is applied directly over [`config/base.cordis.yml`](../config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. + +A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve: + +```yaml +- id: agent-loop + config: + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash +``` + +Inspect the effective tree without booting it: + +```sh +dsh --dump-default-config +dsh --config ./app.cordis.yml --dump-config +``` + +`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. + +## Web and headless + +`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](../config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. + +```sh +dsh web +dsh web --config ./web-profile.cordis.yml +dsh web --dump-default-config +dsh web --dump-config +``` + +The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. + +`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags. + +Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. + +Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution. + +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. + +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. + +## Shared deployment behavior + +The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it. + +Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. + +The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. + +## Source launcher + +Link the source-running launcher onto PATH: + +```sh +ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh +``` + +It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md new file mode 100644 index 0000000000..ca29808a6c --- /dev/null +++ b/apps/cli/reference/README.zh.md @@ -0,0 +1,76 @@ +# `dsh` CLI(命令行界面)行为参考 + +[English](README.md) | 中文 + +本参考定义原始配置、Web 和无头命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 + +## 原始配置 + +原始 `dsh` 必须提供显式 patch 列表配置: + +```sh +dsh --config ./app.cordis.yml +``` + +指定文件通过 Include 插件的 patch 算法直接应用到 [`config/base.cordis.yml`](../config/base.cordis.yml) 之上。它不是完整替代树,也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。基础配置刻意不包含启动 agent(智能体)或交互前端入口;必填 overlay 负责选择这些部署细节。相对配置路径从调用目录解析。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 + +patch 通过 `id` 定位基础配置行,并替换该行完整的 `config` 值,而不是深度合并各键。patch 列表也可插入新行,只要随附 Loader 能解析其插件模块: + +```yaml +- id: agent-loop + config: + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash +``` + +可在不启动的情况下检查生效的配置树: + +```sh +dsh --dump-default-config +dsh --config ./app.cordis.yml --dump-config +``` + +`--dump-default-config` 只打印随附基础配置。`--dump-config` 必须与 `--config` 同时使用,并打印基础配置和带来源注释的 overlay。组合使用 `@cordisjs/plugin-include` 的 `applyEntryPatches` 与 `entryListSchema`;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 + +## Web 与无头模式 + +`dsh web` 启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](../config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续加载它。`dsh web --config <path>` 用显式 patch 列表替代该个人层。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为 Web 宿主 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 挂载客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 + +```sh +dsh web +dsh web --config ./web-profile.cordis.yml +dsh web --dump-default-config +dsh web --dump-config +``` + +生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 + +`dsh -p "task"` 使用同一基础配置和 Web 组合,并加载启动时的个人配置;它在 OS 分配的端口上启动 Web 宿主,运行一个新的持久化会话,打印最终答案并退出。它不接受 `--config` 或原始配置 dump flag。 + +Web 和无头进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果无头模式正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。 + +两种模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。Web 监视有效的个人配置编辑;无头模式只在启动时读取该文件。[app-boot 个人配置契约](../../../packages/ui/app-boot/README.md#personal-config)负责配置层优先级、凭据存储、实时更新失败行为和 `$DSH_HOME` 解析。 + +新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 + +`DSH_TOOLS_MODE` 为 Web/无头进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选 Web overlay:它在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,把原生模型 surface 缩减为持久 `bash` 和 `str_replace_editor`。 + +## 共享部署行为 + +基础配置挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 overlay 插入提供方并启用 `web_fetch` 后,该工具才可用。 + +会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 + +空 `repository-plugins` 行让 Web/无头个人配置和原始 overlay 能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为 overlay 的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。 + +## 源码启动器 + +把源码运行启动器链接到 PATH: + +```sh +ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh +``` + +它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。 diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 28794e73c4..8c40dde156 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -32,39 +32,50 @@ async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void> } /** - * Consume mux frames until the task turn ends, per the cli-demo runOneShot - * correlation precedent: anchor on the first turn/start whose trigger kind is - * 'message' (startup-injected turns are skipped), aggregate text from that - * turn's assistant/message events (last one wins), finish on its turn/end. + * Consume mux frames until the agent reaches idle, per the one-shot CLI + * idle-to-idle contract: the stream opens immediately before the prompt, and + * its first observed turn/start begins the task. Text is the last committed + * assistant message of the whole interval (steering or injected work may run + * further turns before quiescence), and the outcome reason is the final + * turn/end's kind. Idleness is signalled out of band by the caller's + * `agent/status` subscription; the stream itself carries no status frame. + * @param frames - the mux stream opened before the prompt. + * @param sessionId - the headless session. + * @param idle - resolves when the agent reaches quiescence. + * @returns the aggregated outcome. */ -async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> { - let targetTurn: number | undefined +async function consumeUntilIdle( + frames: AsyncIterable<RpcRequest<MuxFrame>>, + sessionId: SessionId, + idle: Promise<void>, +): Promise<TurnOutcome> { + let started = false let text = '' - try { - for await (const frame of frames) { - const payload = frame.payload - if (payload.type === 'stream/error') { - process.stderr.write(`dsh: stream error: ${payload.error.message}\n`) - return { text, reason: 'error' } - } - if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue - const event = payload.event - if (targetTurn === undefined) { - if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn - continue - } - if (event.type === 'assistant/message' && event.data.turn === targetTurn) { - const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - if (joined !== '') text = joined - } - if (event.type === 'turn/end' && event.data.turn === targetTurn) { - return { text, reason: event.data.reason.kind } + let reason: string = 'error' + void (async () => { + try { + for await (const frame of frames) { + const payload = frame.payload + if (payload.type === 'stream/error') return + if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue + const event = payload.event + if (event.type === 'turn/start') { + started = true + continue + } + if (!started) continue + if (event.type === 'assistant/message') { + const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (joined !== '') text = joined + } + if (event.type === 'turn/end') reason = event.data.reason.kind } + } catch (error: unknown) { + process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } - } catch (error: unknown) { - process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) - } - return { text, reason: 'error' } + })() + await idle + return { text, reason } } /** @@ -99,7 +110,12 @@ export async function runHeadless(task: string): Promise<void> { // to a remote HTTP carrier unchanged. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const done = consumeUntilTurnEnd(frames, created.sessionId) + const idle = new Promise<void>((resolve) => { + ctx.on('agent/status', (agent, status) => { + if (agent.id === created.sessionId && status === 'idle') resolve() + }) + }) + const done = consumeUntilIdle(frames, created.sessionId, idle) await unwrap(await api.sessions.prompt({ sessionId: created.sessionId, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 08b1c323ba..d2186e097a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tool-bash' +import type {} from '@deepseek-ai/dsh-bash-env' import { AppCLIEntry } from './app-cli-entry.ts' import { createProcessShutdown } from './process-shutdown.ts' diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b9b2eeea1f..44730e9f37 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/bash-env" + }, { "path": "../../packages/bash/tool-bash" }, diff --git a/apps/web/package.json b/apps/web/package.json index 3c8f90b6a0..10c2dc4702 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,9 @@ "./dist/*": "./dist/*", "./package.json": "./package.json" }, + "files": [ + "dist" + ], "scripts": { "build": "vite build", "dev": "vite", @@ -24,6 +27,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index c6c6e627bd..aea33f14f1 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -2,7 +2,6 @@ // the same locale-aware, in-page risk confirmation. Zero model calls: the // scenario boots the shipped Web composition and exercises the real // permission projection, client command path, HTTP RPC, and pushed update. -import { mkdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -12,27 +11,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' - -/** - * connectFreshWorkspace twin over the product default Chinese locale (the - * shared helper's anchors assume the English page every other scenario - * boots; this scenario deliberately keeps zh, so the localized picker - * copy is the anchor set). - */ -async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> { - mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - const pathInput = dialog.getByRole('textbox', { name: '编辑路径' }) - await pathInput.fill(join(root, name)) - await pathInput.press('Enter') - await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') - .waitFor({ timeout: 15_000 }) -} +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index 717cebdd36..3d48442747 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () const row = page.locator('[data-sample="bash"]').first() const call = row.locator('xpath=..') await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') @@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () await call.getByText('OUT', { exact: true }).waitFor() await call.getByText('Wait until cancellation', { exact: false }).waitFor() await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) // The borrowed fixture's UTC date is still the previous day in PDT; diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index c452295d23..b102dff8b8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -7,7 +7,7 @@ // content from the keyless FixtureApiClient transport. // // Component behavior remains owned by per-package suites (SlotTestRuntime -// benches over src). This smoke additionally pins the resident approval +// benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' @@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') - // The resident approval fixture proves the assembled workspace plugin - // distinguishes a blocked running session from an ordinarily busy one. + // The resident fixture has both a question and an approval; composer routing + // exposes the question first, and the assembled workspace plugin mirrors that + // actionable wait instead of the underlying running state. const waitingTitle = await within(tree).findByText('Fixture 历史会话') const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]') if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() - within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Waiting for answer') // Opening a session reaches chat content through the fixture transport. fireEvent.click(waitingTitle) @@ -120,6 +121,21 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) + // Resolve the resident approval so the ordinary composer bar (which owns + // ContextMeter) resumes without replacing the session shell. This minimal + // boot graph intentionally does not mount the separate question UI plugin. + fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) + + // The fixture mirrors all three token-meter projections, so the assembled + // ContextMeter reaches its composition panel instead of only the occupancy + // fallback path. + const contextTrigger = await screen.findByRole('button', { name: /of context used/ }) + fireEvent.click(contextTrigger) + const contextPanel = await screen.findByRole('dialog', { name: 'of context used' }) + within(contextPanel).getByText('System prompt') + within(contextPanel).getByText('Tools') + within(contextPanel).getByText('Messages') + // The write/edit turns render a real diff card through the assembled graph // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the // fixture's raw text. The card is collapsed by default, so expand each edit/ diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 44bc6f0012..b9bebbd897 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -260,7 +260,9 @@ describe('web e2e: long Chat interaction contract', () => { expect(await composer.inputValue()).toBe('') expect(await composer.isEnabled()).toBe(true) expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) - expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1) + expect(child.session.events.filter(event => ( + event.type === 'user/message' && carries(event, CONTINUE_PROMPT) + ))).toHaveLength(1) const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index b4b67bf3a3..6509055036 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -608,6 +608,9 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click() await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) await world.page.setViewportSize({ width: 700, height: 900 }) + // The narrow breakpoint auto-collapses the sidebar. Re-open it because + // this scenario switches sessions while pinning the narrow Chat scroll owner. + await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click() await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) await expectSameFlowTop(world.page, sessionAnchor) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index e46f0c336a..caf9cf13d9 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -1,7 +1,7 @@ // Synthetic long-chat history for browser behavior contracts. The fixture is // generated through Session so pagination exercises the same event shapes as -// persisted conversations, while unique markers let tests identify semantic -// rows without depending on CSS-module names or the eventual virtualizer DOM. +// persisted conversations, while unique markers identify semantic rows +// without depending on CSS-module names or virtualizer DOM positions. import { CallId, createAssistantMessage, @@ -179,12 +179,11 @@ function fixtureLog(session: Session): string { export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture { const turns = options.turns ?? DEFAULT_TURNS const markers = markerHelpers(options.markerPrefix) - const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`)) + const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`)) for (let turn = 1; turn <= turns; turn += 1) { session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const user = session.append('user/message', createUserMessage({ content: text( diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index f2c8c5332d..2daed2c0f6 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -319,10 +319,9 @@ function fixtureLog(session: Session): string { } function smallSidebarFixture(): string { - const session = new Session(SessionId('perf-small-template')) + const session = Session.create(SessionId('perf-small-template')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const user = session.append('user/message', createUserMessage({ content: text('Inspect this compact synthetic session.'), @@ -342,11 +341,10 @@ function smallSidebarFixture(): string { } function longHistoryFixture(): string { - const session = new Session(SessionId(LONG_SESSION_ID)) + const session = Session.create(SessionId(LONG_SESSION_ID)) for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) { session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const user = session.append('user/message', createUserMessage({ content: text( diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts new file mode 100644 index 0000000000..c6b51b02bc --- /dev/null +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -0,0 +1,420 @@ +// Web e2e scenario: the input card holds one horizontal position across the +// Chat and Trajectory tabs. +// +// The composer seat is the same node in both tabs, but it measures itself +// against a different edge in each (see +// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css). +// In Chat it is a sticky CHILD of the column's scroller, so it rides that +// scroller's content box — the box a space-consuming scrollbar shortens. A view +// that opts into a composer overlay (`data-conversation-composer-overlay`, which +// Trajectory declares and which moves the column's own scrolling into the view) +// gets an absolutely positioned seat instead, laid out against the padding box, +// which the scrollbar never reduces. +// +// So the two tabs disagreed by exactly the bar's width for as long as the +// transcript overflowed: the card jumped sideways on every tab switch, and +// inside Chat alone at the moment a growing transcript started to scroll. The +// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`) +// and states the overlay branch as a scroll container on the same axes, so both +// edges are the same edge. +// +// Only a real engine can show this. The seat's geometry is layout: jsdom gives +// every element a zero-sized box and reports no scrollbar at all, so a unit spec +// can assert the declarations exist but not that the two states land in the same +// place. What is asserted here is the user-visible fact — the card does not move +// — measured as the distance between the two tabs' card rectangles. +// +// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, +// which is load-bearing rather than incidental. Under that argument a scroll +// container's bar consumes no layout width at all, so the two tabs agree before +// this change as much as after it and every comparison below holds vacuously — +// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8 +// and 0 with the argument dropped. Dropping it is also the faithful +// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width, +// and a bar that occupies layout space is what the product actually draws. +// +// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto` +// on the scroller, `overflow: hidden` on the overlay branch — and measures the +// same two tabs through it, which is what keeps the equal rectangles above from +// being explained by a tab switch that never reached the layout. It is the +// reported symptom as a number: the card moves 4px, half the 8px band, on each +// edge. +// +// Zero model calls: a seeded cold session renders from its log, and switching +// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url)) +/** + * Committed golden of where the input card sits in each tab, at a wide viewport + * (card at its width cap) and a narrow one (card shrinking with the column). + * + * Absolute coordinates are deliberately absent: they depend on the sidebar's + * laid-out width and on font metrics, so committing them would produce a fixture + * that has to be re-recorded per platform. What is recorded is the distance + * between the two tabs' rectangles, which is zero when the reservation holds and + * the bar's width when it does not — including under the control, so the golden + * carries the difference the fix removes rather than only its absence. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() + +/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */ +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'TAB_GEOMETRY', + title: 'COMPOSER_TAB_GEOMETRY long session', + turns: 24, +}) +const SEED_ID = 'composer-tab-geometry-web-e2e' + +/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */ +const WIDE_VIEWPORT = { width: 1680, height: 1000 } +const NARROW_VIEWPORT = { width: 800, height: 1000 } + +/** + * Resize to one measurement viewport after the responsive sidebar and center + * column finish their track transition. + * @param page - the page under test. + * @param viewport - the viewport dimensions to apply. + * @param sidebarCollapsed - the sidebar state expected at this width. + */ +async function setMeasuredViewport( + page: Page, + viewport: { width: number; height: number }, + sidebarCollapsed: boolean, +): Promise<void> { + await page.setViewportSize(viewport) + await page.locator('[data-sidebar-collapsed="true"]').waitFor({ + state: sidebarCollapsed ? 'attached' : 'detached', + timeout: 10_000, + }) + await page.locator('[data-conversation-scroll]').evaluate(async (host) => { + const deadline = performance.now() + 5_000 + let previous = host.getBoundingClientRect().width + let stableFrames = 0 + while (performance.now() < deadline) { + await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const current = host.getBoundingClientRect().width + stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0 + if (stableFrames >= 3) return + previous = current + } + throw new Error('conversation width did not settle after the viewport changed') + }) +} + +/** + * The pre-fix cascade, injected into the page: the reservation dropped and the + * overlay branch back to a hidden box. `!important` beats the module rules + * without a rebuild, and the id lets the control be lifted again in the same + * session. + */ +const CONTROL_STYLE_ID = 'composer-tab-geometry-control' +const CONTROL_CSS = ` +[data-conversation-scroll] { scrollbar-gutter: auto !important; } +[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; } +` + +/** The column scroller and the input card as the browser lays them out, in one tab. */ +interface TabMetrics { + /** Resolved `scrollbar-gutter` on the column's scroller. */ + gutter: string + /** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */ + overflowX: string + /** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */ + overflowY: string + /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ + band: number + /** True when the column's scroller actually scrolls — only Chat does. */ + scrolls: boolean + /** Left edge of the input card in viewport coordinates. */ + cardLeft: number + /** Right edge of the input card. */ + cardRight: number + /** Width of the input card, capped at the composer card max width. */ + cardWidth: number +} + +/** One tab's metrics beside the other's, plus the distances between them. */ +interface TabComparison { + chat: TabMetrics + trajectory: TabMetrics + /** Distance between the two tabs' card left edges: 0 when the card holds its position. */ + leftShift: number + /** Distance between the two tabs' card right edges. */ + rightShift: number + /** Difference between the two tabs' card widths. */ + widthShift: number +} + +/** + * Measure the column scroller and the input card in the tab currently shown. + * @param page - the page under test. + * @returns the scroller's resolved overflow style and the card's rectangle. + */ +function measureTab(page: Page): Promise<TabMetrics> { + return page.evaluate(() => { + const host = document.querySelector<HTMLElement>('[data-conversation-scroll]') + if (host === null) throw new Error('conversation column scroller not in the DOM') + const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]') + if (card === null) throw new Error('no input card inside the composer seat') + const style = getComputedStyle(host) + const hostRect = host.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + return { + gutter: style.scrollbarGutter, + overflowX: style.overflowX, + overflowY: style.overflowY, + band: hostRect.width - host.clientWidth, + scrolls: host.scrollHeight > host.clientHeight, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardWidth: cardRect.width, + } + }) +} + +/** + * Show one tab and wait for the view that owns it to be laid out. + * @param page - the page under test. + * @param tab - the tab to show. + */ +async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> { + await page.getByRole('tab', { name: tab, exact: true }).click() + if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + // Both measurements are taken after a paint, so a rectangle read mid-transition + // cannot be reported as a shift the cascade did not cause. + await page.evaluate(() => new Promise<void>((settle) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) }) + })) +} + +/** + * Measure both tabs and the distances between them, leaving Chat shown. + * @param page - the page under test. + * @returns each tab's metrics and the card's displacement between them. + */ +async function compareTabs(page: Page): Promise<TabComparison> { + await showTab(page, 'Chat') + const chat = await measureTab(page) + await showTab(page, 'Trajectory') + const trajectory = await measureTab(page) + await showTab(page, 'Chat') + return { + chat, + trajectory, + leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft), + rightShift: Math.abs(trajectory.cardRight - chat.cardRight), + widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth), + } +} + +/** + * Run the pre-fix cascade in the page for one measurement, then lift it. + * @param page - the page under test. + * @returns the comparison as the column laid out before this change. + */ +async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> { + await page.evaluate(({ id, css }) => { + const style = document.createElement('style') + style.id = id + style.textContent = css + document.head.append(style) + }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS }) + try { + return await compareTabs(page) + } finally { + await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID) + } +} + +/** + * Open the seeded session from the sidebar search. + * + * Cold summaries carry the temp workspace's basename, so the persisted first + * message is the stable identity to search for, and the query itself drives the + * lazy content-index reconciliation. Hand-rolled polling because `expect.poll` + * is test-scoped and this runs in `beforeAll`. + * @param page - the page under test. + */ +async function openSeededSession(page: Page): Promise<void> { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + for (;;) { + if (await results.count() === 1) break + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +/** + * Render the golden body. + * @param wide - comparison at the viewport where the card sits at its width cap. + * @param narrow - comparison at the viewport where the card shrinks with the column. + * @param control - comparison at the wide viewport with the reservation removed. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { + const section = (name: string, comparison: TabComparison): string[] => [ + `## ${name}`, + '', + `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`, + `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`, + `- Chat reserved band: ${String(comparison.chat.band)}px`, + `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`, + `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`, + `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`, + `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`, + `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`, + `- input card width changes between tabs: ${String(comparison.widthShift)}px`, + '', + ] + return [ + '# Input card position across the Chat and Trajectory tabs', + '', + ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide), + ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow), + ...section('Wide viewport, reservation removed in the page (control)', control), + ].join('\n').trimEnd() +} + +describe('web e2e: input card position across view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + // Scrollbars must take layout space here or the scenario proves nothing; + // see the file header for the measurement behind dropping this argument. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) + page = await newEnglishPage(browser, WIDE_VIEWPORT.height) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reserves the same gutter in both tabs while the transcript scrolls', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + // Vacuity guard, in two parts. A transcript that does not overflow gives + // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either + // would make the tabs agree without the reservation doing anything. + await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) + const comparison = await compareTabs(page) + expect(comparison.chat.band).toBeGreaterThan(0) + // The reservation reaches both states, which is the whole change: the same + // band, on a box that scrolls and on one that only holds a view. + expect(comparison.chat.gutter).toBe('stable') + expect(comparison.trajectory.gutter).toBe('stable') + expect(comparison.trajectory.band).toBe(comparison.chat.band) + // Declared as a scroll container on both axes rather than left to compute: + // `overflow: hidden` would drop the reservation in WebKit, and a `visible` + // horizontal axis computes to `auto` beside a scrolling one. + expect(comparison.trajectory.overflowY).toBe('auto') + expect(comparison.trajectory.overflowX).toBe('hidden') + // Only Chat scrolls this box; the Trajectory view owns its own scrollers. + expect(comparison.trajectory.scrolls).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place when the tab changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide')) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + const comparison = await compareTabs(page) + // The reported symptom as a number. At this viewport the card sits at its + // width cap, so the pre-fix shift showed up as a centring difference — half + // the band on each edge — rather than as a width change. + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place at a viewport where it shrinks with the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow')) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + const capped = await measureTab(page) + await setMeasuredViewport(page, NARROW_VIEWPORT, true) + const comparison = await compareTabs(page) + // The other geometry, and a different failure: below the cap the card takes + // the column's width, so an unreserved gutter changed its WIDTH by the whole + // band instead of shifting it by half. Asserted against the capped + // measurement rather than against the cap's pixel value, which belongs to + // the stylesheet. + expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth) + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('moves the card again once the reservation is removed in the page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + // The control: without it, equal rectangles could also mean the tab switch + // never reached the layout. Under the pre-fix cascade the Chat scroller keeps + // its bar and the Trajectory branch goes back to a hidden box with none, and + // the card moves by half the band on each edge. + const comparison = await compareTabsWithoutReservation(page) + expect(comparison.chat.gutter).toBe('auto') + expect(comparison.chat.band).toBeGreaterThan(0) + expect(comparison.trajectory.band).toBe(0) + expect(comparison.leftShift).toBe(comparison.chat.band / 2) + expect(comparison.rightShift).toBe(comparison.chat.band / 2) + // Restoring the sheet restores the fix, so the control cannot leak into the + // remaining measurements. + const restored = await compareTabs(page) + expect(restored.leftShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('matches the committed tab geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden')) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + const wide = await compareTabs(page) + await setMeasuredViewport(page, NARROW_VIEWPORT, true) + const narrow = await compareTabs(page) + await setMeasuredViewport(page, WIDE_VIEWPORT, false) + const control = await compareTabsWithoutReservation(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('commits exactly the fixtures it reads', async () => { + // The seeded session is generated in-process, so the geometry golden is the + // whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index a806fcaa67..ac439bf9d8 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -29,9 +29,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end', ) const reason = turnEnd?.data.reason - const reasonSummary = reason?.kind === 'error' - ? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status } - : { kind: reason?.kind } + const reasonSummary = { kind: reason?.kind } expect(reasonSummary).toEqual({ kind: 'completed' }) const calls = events.filter( diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index b757af08d7..8c81f55810 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -104,6 +104,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await input.press('Enter') const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) await planButton.waitFor({ timeout: 10_000 }) + // The golden encodes an empty composer, and the button arriving does not + // mean the submitted text is gone yet: under load the capture caught a + // textbox still holding `/plan`. + await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) const planStyle = await planButton.evaluate((element) => { diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 387faab3b7..e3a427c446 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -192,9 +192,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const { settled } = await sendPrompt() await settled await page.getByRole('tab', { name: 'Trajectory' }).click() + // The boundary marker row itself is a 0-height hairline except at the + // table tail; the marker button is absolutely positioned and stays + // visible, so wait on it directly. const tailRequest = page.locator('tr[data-request-only="true"]').last() - await tailRequest.waitFor({ timeout: 10_000 }) const requestMarker = tailRequest.getByRole('button', { name: /Request #/ }) + await requestMarker.waitFor({ timeout: 10_000 }) const markerWithinTable = await requestMarker.evaluate((element) => { const marker = element.getBoundingClientRect() diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts new file mode 100644 index 0000000000..dfa669d47d --- /dev/null +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -0,0 +1,128 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-cjk-strong', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-cjk-strong/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-cjk-strong-web-e2e' +const DONE = 'CJK_STRONG_DONE' +const CASES = [ + ['**注意:**内容', '注意:', '注意:内容'], + ['**Notice:**内容', 'Notice:', 'Notice:内容'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'], + ['**句号。**后续', '句号。', '句号。后续'], + ['**Period.**后续', 'Period.', 'Period.后续'], + ['**提醒!**继续', '提醒!', '提醒!继续'], + ['**Warning!**继续', 'Warning!', 'Warning!继续'], +] as const + +/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */ +function markdownFixture(): string { + const session = Session.create(SessionId('markdown-cjk-strong-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'CJK strong emphasis', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## CJK strong emphasis', + '', + ...CASES.flatMap(([markdown]) => [markdown, '']), + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: CJK-adjacent Markdown strong emphasis', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders punctuation-terminated strong spans before adjacent CJK text', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-cjk-strong')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + const strong = page.locator('[class*="markdown"] strong') + await expect.poll(() => strong.count(), { timeout: 10_000 }).toBe(CASES.length) + expect(await strong.allTextContents()).toEqual(CASES.map(([, expected]) => expected)) + for (const [, , paragraph] of CASES) { + expect(await page.getByText(paragraph, { exact: true }).count()).toBe(1) + } + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 8dce7f405b..adf4e0b1b3 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -82,11 +82,8 @@ async function stopServer(server: Server): Promise<void> { /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ function markdownImageFixture(remoteUrl: string): string { - const session = new Session(SessionId('markdown-image-source')) - session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) + const session = Session.create(SessionId('markdown-image-source')) + session.append('turn/start', { turn: 1 }) const user = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Show the Markdown image policy.' }], source: { kind: 'user' }, diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts new file mode 100644 index 0000000000..8839a1b8d1 --- /dev/null +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -0,0 +1,138 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-inline-code-links', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-inline-code-links/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-inline-code-links-web-e2e' +const DONE = 'INLINE_CODE_LINK_DONE' + +/** Build a settled assistant reply with linkable URL code and inert code controls. */ +function markdownFixture(linkUrl: string): string { + const session = Session.create(SessionId('markdown-inline-code-links-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the local preview URL.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Inline code links', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Inline code links', + '', + `Preview: \`${linkUrl}\``, + '', + `Standard: [Open preview](${linkUrl})`, + '', + `Command: \`curl ${linkUrl}\``, + '', + 'Unsafe: `javascript:alert(1)`', + '', + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: Markdown inline-code links', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let linkUrl: string + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString() + await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('opens a complete HTTP URL from inline code and leaves other code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-inline-code-links')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + const inlineCodeLink = page.locator('[class*="markdown"] code a') + await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1) + expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl) + expect(await inlineCodeLink.getAttribute('target')).toBe('_blank') + expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer') + await inlineCodeLink.focus() + expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true) + + const popupPromise = page.waitForEvent('popup') + await inlineCodeLink.click() + const popup = await popupPromise + await popup.waitForURL(linkUrl, { timeout: 15_000 }) + expect(popup.url()).toBe(linkUrl) + await popup.close() + + expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0) + expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + .split(linkUrl).join('{{linkUrl}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts new file mode 100644 index 0000000000..b183fe7df5 --- /dev/null +++ b/apps/web/tests/math-rendering.e2e.ts @@ -0,0 +1,130 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'math-rendering-web-e2e' +const DONE = 'MATH_RENDERING_DONE' + +/** Build a settled assistant reply that exercises every supported math delimiter. */ +function mathFixture(): string { + const session = Session.create(SessionId('math-rendering-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { + turn: 1, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Render this mathematical proof.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Math rendering', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Math rendering', + '', + 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + '', + '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + '', + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: settled Markdown math rendering', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mathFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) + await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) + expect(await page.locator('.katex-error').count()).toBe(0) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aa64816420..aac2c4806c 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -126,9 +126,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) - await page.getByRole('button', { - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index c46127c9db..1d9117dc85 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '+ 添加提供方' }) + const add = dialog.getByRole('button', { name: '添加提供方' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 1ec36454d0..51fa84af3d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -12,7 +12,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -22,6 +22,7 @@ import { const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md') const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') +const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md') const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => { @@ -160,7 +161,60 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models')) + // Opened here rather than inherited: the credential test reloads the page + // to exercise the welcome step, so nothing carries an open dialog across. + await page.getByRole('button', { name: '设置', exact: true }).click() + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() + await deepSeek.waitFor({ timeout: 10_000 }) + await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() + await settings.getByText('自定义设置').click() + await settings.getByRole('button', { name: /删除模型/ }).first().click() + await settings.getByRole('button', { name: '添加模型' }).click() + const customModelId = settings.getByLabel('模型 ID 2') + await customModelId.fill('private-preview') + await settings.getByLabel('显示名称 2').fill('Private Preview') + // Capacities live behind the row's own disclosure, as in the pi-ai form. + await settings.getByRole('button', { name: '容量 2' }).click() + await settings.getByLabel('上下文窗口 2').fill('131072') + await settings.getByLabel('最大输出 token 数 2').fill('64K') + + const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) + await settings.getByRole('button', { name: '保存', exact: true }).click() + await customModelId.waitFor({ state: 'detached', timeout: 15_000 }) + + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('id: deepseek-v4-pro') + expect(document).toContain('id: private-preview') + expect(document).toContain('name: Private Preview') + expect(document).toContain('contextWindow: 131072') + expect(document).toContain('maxTokens: 64000') + expect(document).not.toContain('id: deepseek-v4-flash') + + await page.keyboard.press('Escape') + // A connected Workspace is what puts a live composer — and its model + // trigger — on the page; the scaffold boots without one. + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') + + const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true }) + await modelTrigger.waitFor({ timeout: 10_000 }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0) + await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md']) + await assertFixtureInventory( + SNAPSHOT_DIR, + ['missing.expected.md', 'models.expected.md', 'welcome.expected.md'], + ) }) }) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index e37e3954ab..8c2462d7ee 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // The waiting golden owns the decision card; the approved golden owns the // transcript the approval leaves behind — the state the card cannot see. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') const MODE = webSnapshotMode() @@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } await card.getByRole('button', { name: 'Approve' }).click() @@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Card gone; regular input restored. expect(await page.locator('[data-plan-review-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) @@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', + ]) }) }) diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts new file mode 100644 index 0000000000..26f6a8319e --- /dev/null +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -0,0 +1,103 @@ +// Keyless browser regression for pwsh UI parity with bash: a seeded session +// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the +// api-proxy recomputes presentation views from logged args/result content) +// must render as a bash-shaped terminal card with the parsed exit-status +// pill — not the generic console-fenced card the pwsh presenter used to +// emit. The seed is authored, not recorded: its header line carries no `cwd` +// field (seedSession writes the session cwd itself, and a Windows temp path +// substituted into the header would not round-trip through its JSON parse), +// and no event references the workspace, so the lane replays on any host +// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay +// (the shipped tree keeps the bash stack). +import { spawnSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url)) +const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') +const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') +const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url)) +const PROMPT = 'Run a PowerShell command that fails, then stop.' +const SEED_ID = 'pwsh-terminal-web-e2e' +const MODE = webSnapshotMode() + +// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local; +// a host without a usable `pwsh` cannot boot it, so the lane self-skips, +// mirroring the pwshOnly ACP scenarios. The probe follows the executor's own +// resolution (Program Files installs on Windows are found even when bare +// `pwsh` is not on PATH), the same judgment the tool-pwsh tests reuse; record +// mode skips the lane anyway, so the probe stays inert there. +const HAS_PWSH = MODE === 'record' ? false : spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + + beforeAll(async () => { + const fixture = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT]) + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal')) + // Open the seeded session through content search: the sidebar groups + // sessions by workspace and its row order is world-dependent, while the + // search index covers the seeded log deterministically. + const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + await search.fill('Run a PowerShell command') + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) + await result.click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 }) + // The tool row is expand-gated: the settled bash-shaped row carries the + // shell-family variant, and the terminal card lives in the expanded body. + const row = page.locator('[data-tool="pwsh"]').first() + await row.waitFor({ timeout: 15_000 }) + if (await row.getAttribute('aria-expanded') !== 'true') await row.click() + const card = page.locator('[data-terminal]').first() + await card.waitFor({ timeout: 15_000 }) + // The parsed exit pill replaces the `[exit code: 1]` marker in the output + // body — the bash tool's terminal presentation, not the generic fence. + const text = await card.textContent() + expect(text).toContain('exit code 1') + expect(text).toContain('Get-Item : Cannot find path') + expect(text).not.toContain('[exit code: 1]') + const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd)) + // normalizeAria collapses the workspace basename with a '/' split, which + // misses Windows temp paths; collapse it here too (a no-op on POSIX) so + // the golden is platform-independent. + .split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE) + }, 60_000) + + it('guards the lane fixture inventory', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md']) + }) +}) diff --git a/apps/web/tests/pwsh-terminal.overlay.yml b/apps/web/tests/pwsh-terminal.overlay.yml new file mode 100644 index 0000000000..59830e3274 --- /dev/null +++ b/apps/web/tests/pwsh-terminal.overlay.yml @@ -0,0 +1,20 @@ +# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell +# twin: the bash executor row is disabled (patches cannot rename a row — `name` +# is a guard) and the pwsh executor + tool are inserted. The permission service +# refuses an unconfined executor by design (presets bundle a sandbox mode), so +# its row is disabled too — this lane renders a seeded session, never a +# permission decision. The seeded scenario renders the logged pwsh call/result +# through the real tools on replay; no command executes, but the composition +# must boot the pwsh executor, so the lane skips on hosts without a usable +# `pwsh`. +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + disabled: true +- id: permission + name: '@deepseek-ai/dsh-permission' + disabled: true +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ebbaf759c4..6f865567bb 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. @@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => { await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } // Squeezed card: the option rows are the capped card's scroll content, so @@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip // rendered as history (question tool row + DONE), composer takeover gone. @@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', + 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', ]) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 4f0c52d9c6..a10ca7188e 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -32,6 +32,7 @@ const REMOVE = 'Queue item to remove' const EDIT = 'Queue item to edit' const EDITED = 'Edited queue item' const TAIL = 'Queue item preserved after stop' +const WAKE = 'Wake the preserved queue' /** Durable turn-end classifications observed by the scenario. */ function turnEndReasons(events: readonly SessionEvent[]): string[] { @@ -63,13 +64,13 @@ describe('web e2e: queue row actions', () => { it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) const readyFile = join(overrideDir, '.hang-ready') - const nextReadyFile = join(overrideDir, '.next-hang-ready') const overridePath = join(overrideDir, 'replay.override.json') const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) expect(recorded).toHaveLength(1) const replay: ReplayEntry[] = [ { kind: 'hang', readyFile }, - { kind: 'hang', readyFile: nextReadyFile }, + recorded[0]!, + recorded[0]!, recorded[0]!, ] await writeFile(overridePath, JSON.stringify(replay)) @@ -86,7 +87,7 @@ describe('web e2e: queue row actions', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) const input = page.locator('textarea').first() - const settled = scaffold.whenTurnSettled() + const firstSettled = scaffold.whenTurnSettled() await input.fill(ACTIVE_PROMPT) await input.press('Enter') await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) @@ -157,19 +158,24 @@ describe('web e2e: queue row actions', () => { ).toBe(2) await page.getByRole('button', { name: 'Stop generating' }).click() - await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true) - await page.getByText(TAIL, { exact: true }).waitFor() + await firstSettled + await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) + .toBe(0) await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count()) - .toBe(1) + .toBe(2) const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) - await page.getByRole('button', { name: 'Stop generating' }).click() + const settled = scaffold.whenTurnSettled() + await input.fill(WAKE) + await input.press('Enter') await settled - expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed']) - expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')) - .toHaveLength(3) + await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 }) + .toEqual(['aborted', 'completed', 'completed', 'completed']) + expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE]) await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0) }, 120_000) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6b8067235f..52eb7f151d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -2,8 +2,8 @@ // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). // Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the -// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the -// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: +// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket +// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: // replay (default, keyless: normally disables the llm-deepseek row and // inserts dsh-llm-replay in providers mode), record (real adapter + key, // harvests fixtures from live session memory), refresh (keyless replay that @@ -56,7 +56,7 @@ import type {} from '@deepseek-ai/dsh-agent' import { prepareWebRuntimeContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' -/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ +/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ export type WebSnapshotMode = 'replay' | 'record' | 'refresh' /** @@ -379,26 +379,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We ctx, workspaceCwd, persistenceRoot, - // Barrier stack: the in-process turn/end identifies the session, then - // agent.whenIdle() covers the persistence flush (the idle flip follows - // the flush), and the caller's browser settled-poll comes last because - // host completion strictly precedes render. + // Barrier stack: the in-process turn/end identifies the session, its + // explicit flush makes the transcript durable, and the caller's browser + // settled-poll comes last because host completion strictly precedes render. whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> { return new Promise<SessionId>((resolveSettled, reject) => { const timer = setTimeout(() => { off() reject(new Error(`no turn/end within ${timeoutMs}ms`)) }, timeoutMs) - const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + const off = ctx.on('session/event', (session: Session, event: SessionEvent) => { if (event.type !== 'turn/end') return clearTimeout(timer) off() - const agent = ctx.agents.get(session.id) - if (agent === undefined) { - reject(new Error(`turn/end for ${session.id} but no live agent`)) - return - } - agent.whenIdle().then(() => { resolveSettled(session.id) }, reject) + ctx.sessions.flush(session) + .then(() => { resolveSettled(session.id) }, reject) }) }) }, @@ -481,15 +476,29 @@ export function fixtureUserPrompts(fixtureText: string): string[] { * @param id - the seeded session id (stable for deterministic goldens). * @returns the seeded id. */ -export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> { +/** + * Realize a recorded seed fixture against one scaffold: substitute the + * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the + * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to + * price content exactly as the host will fold it) and still pass the result + * through {@link seedSession}. + * @param scaffold - the booted scaffold whose workspace the seed targets. + * @param fixtureText - the committed seed fixture text. + * @param id - the session id the seed is realized for. + * @returns the realized fixture text. + */ +export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, id: string): string { const realized = fixtureText .split('{{sessionId}}').join(id) .split('{{cwd}}').join(scaffold.workspaceCwd) const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd - const rewritten = fixtureCwd === undefined + return fixtureCwd === undefined ? realized : realized.split(fixtureCwd).join(scaffold.workspaceCwd) - const events = parseSessionLog(rewritten) +} + +export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> { + const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id)) if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! // An open final turn would be mutated by resume's crash repair on first @@ -523,8 +532,14 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id } /** - * Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration - * volatility collapse to stable tokens. + * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and + * decode-throughput volatility collapse to stable tokens. + * + * Throughput needs a token for the same reason durations do, and no fixture + * can supply one: the figure divides a replayed step's output tokens by the + * wall time the local run took to stream them, so it moves between two runs + * on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay + * (26333 tok/s for a 3 ms stream). */ function normalizeAria(snapshot: string, workspaceCwd: string): string { // The session heading renders the workspace's basename, not the full @@ -534,14 +549,17 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') + // The optional space in `\d+m ?\d+s` covers both minute spellings: the + // stats line's compact `2m42s` and the message-chrome template's `2m 42s`. .replace( - /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g, + /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m ?\d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g, duration => duration.startsWith('~') ? duration : '{{duration}}', ) .replace( /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g, duration => duration.startsWith('约') ? duration : '{{duration}}', ) + .replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}') // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') @@ -592,9 +610,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string, } /** - * Fixture-inventory guard (the TUI afterAll shape): the scenario directory - * holds exactly the expected files and every committed JSONL is a scrub - * fixed-point without a run-local browser RPC id. + * Fixture-inventory guard: the scenario directory holds exactly the expected + * files and every committed JSONL is a scrub fixed-point without a run-local + * browser RPC id. * @param dir - the scenario snapshot directory. * @param expected - the exact expected file inventory. */ diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ba0cb50e40..f9ef1055d1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -16,11 +16,14 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, realizeSeedFixture, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' @@ -41,24 +44,28 @@ const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and * deterministic condition before seeding it cold, so the scenario pins the bug * this change fixes — a landed compaction must not erase history the reader * already saw — through the real host and the real browser. - * @param raw - the committed seed fixture text. + * @param raw - the seed fixture text, already realized (placeholder-free) so + * the shadow price below is computed from the exact strings the host folds. + * @param meter - the composed token meter; the appended `compact/summary`'s + * shadow price must be the exact heuristic price of the shadowed nodes, the + * way compact-basic derives it, because the token-meter projections subtract + * it verbatim. * @returns the fixture with a compacted turn appended. */ -function withCompaction(raw: string): string { +function withCompaction(raw: string, meter: TokenMeterService): string { const lines = raw.trimEnd().split('\n') const events = lines.slice(1).map(line => JSON.parse(line) as { type: string seq: number time: number surfaceOp?: unknown - data?: { turn?: unknown } + data?: { turn?: unknown; message?: unknown; content?: unknown; callId?: unknown; isError?: unknown } }) const surfaceSeqs = events .filter(event => event.surfaceOp === 'append' && (event.type === 'user/message' || event.type === 'assistant/message' - || event.type === 'tool/result' - || event.type === 'steering/message')) + || event.type === 'tool/result')) .map(event => event.seq) const first = surfaceSeqs[0] const last = surfaceSeqs.at(-1) @@ -86,8 +93,33 @@ function withCompaction(raw: string): string { lines.push(JSON.stringify({ ...event, seq: taken, time: time++ })) return taken } - at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } }) + at({ type: 'turn/start', data: { turn } }) const startSeq = at({ type: 'compact/start', data: { turn } }) + // Load-bearing exactness: the projections subtract this count verbatim, so + // it must equal what the host's fold prices for these nodes. The estimator + // prices message CONTENT only, so a minimal wrapper per storage shape is + // exact — pre-identity rows carry bare `content` (the persistence read path + // upgrades them), a current row carries the full `message` envelope. + const priceRow = (row: (typeof events)[number]): number => { + if (row.data?.message !== undefined) { + const message = deriveEventMessage(row as unknown as SessionEvent) + return message === null ? 0 : meter.estimateMessage(message) + } + const content = row.data?.content as ContentBlock[] + if (row.type === 'tool/result') { + return meter.estimateMessage({ + content: [{ type: 'tool-result', toolCallId: row.data?.callId, content, isError: row.data?.isError === true }], + } as unknown as Message) + } + // An empty-content assistant message derives no transcript entry. + if (row.type === 'assistant/message' && content.length === 0) return 0 + return meter.estimateMessage({ content } as unknown as Message) + } + const shadowedTokenCount = surfaceSeqs.reduce((total, surfaceSeq) => { + const event = events.find(candidate => candidate.seq === surfaceSeq) + if (event === undefined) throw new Error(`seeded-history compaction: shadowed seq ${surfaceSeq} is not in the seed`) + return total + priceRow(event) + }, 0) const summarySeq = at({ type: 'compact/summary', data: { @@ -97,7 +129,7 @@ function withCompaction(raw: string): string { }], shadowedRange: { start: first, end: last }, shadowedSeqs: surfaceSeqs, - shadowedTokenCount: 10_000, + shadowedTokenCount, provider: 'snapshot', model: 'snapshot-compactor', }, @@ -138,7 +170,10 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - await seedSession(scaffold, withCompaction(raw), SEED_ID) + const meter = scaffold.ctx.get('tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the composed token meter') + const realized = realizeSeedFixture(scaffold, raw, SEED_ID) + await seedSession(scaffold, withCompaction(realized, meter), SEED_ID) } browser = await chromium.launch() page = await newEnglishPage(browser) @@ -216,7 +251,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - agent.inject(createUserMessage({ + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: '<system-reminder>\n' @@ -227,6 +262,7 @@ describe('web e2e: seeded history renders through cold resume', () => { }], source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', @@ -235,18 +271,20 @@ describe('web e2e: seeded history renders through cold resume', () => { digest: 'context-injection-browser-snapshot', }], }, - })) - await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 }) + }), { surfaceOp: 'append' }) + // The header names the producer the durable source records, so the + // reconciled instruction file is readable without expanding the row. + await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) + .waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) - await page.getByRole('button', { - // This scenario deliberately leaves the LLM seam open to prove zero - // model calls. History still restores the selected id, but no catalog - // adapter exists to provide its presentation name. - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + // This scenario deliberately leaves the LLM seam open to prove zero + // model calls. History still restores the routed id, but without an + // advertised catalog row the selector prompts for a listed replacement. + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -254,7 +292,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection' }) + const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() @@ -265,6 +303,10 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') const body = page.locator('[data-context-injection-body]') await body.waitFor({ timeout: 5_000 }) + // The instructions form names the file it reconciled above the text, and + // the text keeps the framing the model read rather than a cleaned excerpt. + expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded']) + expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>') const headerBox = await disclosure.boundingBox() const bodyBox = await body.boundingBox() if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') @@ -357,21 +399,22 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) }, 60_000) - it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => { + it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - agent.inject(createUserMessage({ + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Short injected context.' }], source: { kind: 'plugin', plugin: 'fixture' }, - })) + }), { surfaceOp: 'append' }) - const disclosures = page.getByRole('button', { name: 'Context injection' }) - await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2) - const disclosure = disclosures.nth(1) + const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true }) + await disclosure.waitFor({ timeout: 10_000 }) await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const body = page.locator('[data-context-injection-body]') + // The instructions row above stays expanded from the geometry case; the + // opaque body is the one without a declared form. + const body = page.locator('[data-context-injection-body]:not([data-context-form])') const bodyBox = await body.boundingBox() if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable') expect(bodyBox.height).toBeLessThan(141) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 19e1b33538..43500585d8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) + await openDocument.waitFor({ timeout: 10_000 }) + let openRequests = 0 + await page.route('**/api/settings.openDocument', async (route) => { + const envelope = route.request().postDataJSON() as { + rpcId: string + payload: Record<string, never> + } + expect(envelope.payload).toEqual({}) + openRequests += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + type: 'server-response', + rpcId: envelope.rpcId, + result: { ok: true, value: { opened: true } }, + }), + }) + }) + await openDocument.click() + await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) + await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) + await page.unroute('**/api/settings.openDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index bb516efd94..03faab05cd 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -196,16 +196,19 @@ describe('dsh web keyless CLI smoke', () => { messages?: { role?: string; content?: string }[] tools?: { function?: { name?: string } }[] } - let resolveProviderRequest!: (request: NativeProviderRequest) => void - const providerRequest = new Promise<NativeProviderRequest>((resolve) => { - resolveProviderRequest = resolve + let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void + const requests: NativeProviderRequest[] = [] + const providerRequests = new Promise<NativeProviderRequest[]>((resolve) => { + resolveProviderRequests = resolve }) const provider = createServer((request, response) => { let body = '' request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as NativeProviderRequest) + const parsed = JSON.parse(body) as NativeProviderRequest + if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed) + if (requests.length === 1) resolveProviderRequests(requests) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end([ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', @@ -244,14 +247,16 @@ describe('dsh web keyless CLI smoke', () => { mode: 'queue', content: [{ type: 'text', text: 'go' }], }) - const captured = await Promise.race([ - providerRequest, + const capturedRequests = await Promise.race([ + providerRequests, new Promise<never>((_resolve, reject) => { setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() }), ]) - expect(captured.messages?.some(message => - message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false) + const captured = capturedRequests[0] + if (captured === undefined) { + throw new Error('provider did not receive the workspace projection request') + } const workspaceMessage = captured.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) const systemMessage = captured.messages?.find(message => message.role === 'system') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..1b9e6aa339 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -10,14 +10,14 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection -- 'button "Failed Bash Error: command aborted" [expanded]': + - text: Context injection @deepseek-ai/dsh-system-prompt +- 'button "Failed Bash Error: tool call aborted" [expanded]': - img - - text: "Failed Bash Error: command aborted" -- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" + - text: "Failed Bash Error: tool call aborted" +- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" - 'button "Failed Bash Error: tool call aborted before dispatch"': - img @@ -26,8 +26,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok +- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 42758237bb..0c2cf8604c 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -44,5 +44,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "7% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 52% Input 17.2K tok · Output 252 tok diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md new file mode 100644 index 0000000000..b226cb845d --- /dev/null +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -0,0 +1,37 @@ +# Input card position across the Chat and Trajectory tabs + +## Wide viewport (1680px, card at its cap) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Narrow viewport (800px, card shrinking with the column) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Wide viewport, reservation removed in the page (control) + +- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter auto, overflow hidden/hidden +- Trajectory scroller scrolls: false +- Trajectory reserved band: 0px +- input card left edge moves between tabs: 4px +- input card right edge moves between tabs: 4px +- input card width changes between tabs: 0px diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 85ef8932b2..33b1d6cd0f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to:": - img - img @@ -51,7 +51,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -59,5 +59,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "13% of context used" - button "Send message" [disabled] -- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok +- text: 1 turns · 4 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 77% Input 66.5K tok · Output 312 tok diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 159e77d5bc..aebc2a45b6 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -39,5 +39,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "6% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 99% Input 15.7K tok · Output 111 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index bdb07876a3..728dc768f8 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,6 +28,7 @@ - textbox "Describe what you want to build" - button "Commands": - img +- tooltip "Commands" - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 8c5cf915dc..6b4d7633e5 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -30,8 +30,8 @@ - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: Details diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index f6f965337e..6b6671ec01 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to reply with a single word. Let me comply.": - img - img @@ -23,7 +23,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -31,5 +31,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "6% of context used" - button "Send message" [disabled] -- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 21 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index cd06310db0..9735b8acfe 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -10,17 +10,17 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1a4aec678c..be1d936dd2 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c50b440f86..6e81c87205 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 5f834e15f0..f127d3e8d1 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": @@ -25,7 +25,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -33,5 +33,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "6% of context used" - button "Send message" [disabled] -- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 79 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md new file mode 100644 index 0000000000..68a4df5603 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "CJK strong emphasis" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Render adjacent CJK strong emphasis. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "CJK strong emphasis" [level=2] +- paragraph: + - strong: 注意: + - text: 内容 +- paragraph: + - strong: "Notice:" + - text: 内容 +- paragraph: + - strong: 事件中间件(waterfall) + - text: 实现 +- paragraph: + - strong: 事件中间件(waterfall) + - text: 实现 +- paragraph: + - strong: 句号。 + - text: 后续 +- paragraph: + - strong: Period. + - text: 后续 +- paragraph: + - strong: 提醒! + - text: 继续 +- paragraph: + - strong: Warning! + - text: 继续 +- paragraph: CJK_STRONG_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}}Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 0f261e5474..fbdbff395a 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -19,13 +19,13 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md new file mode 100644 index 0000000000..059849223c --- /dev/null +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -0,0 +1,43 @@ +- banner: + - navigation "Session hierarchy": + - button "Inline code links" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the local preview URL. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Inline code links" [level=2] +- paragraph: + - text: "Preview:" + - code: + - link "{{linkUrl}}": + - /url: {{linkUrl}} +- paragraph: + - text: "Standard:" + - link "Open preview": + - /url: {{linkUrl}} +- paragraph: + - text: "Command:" + - code: curl {{linkUrl}} +- paragraph: + - text: "Unsafe:" + - code: javascript:alert(1) +- paragraph: INLINE_CODE_LINK_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}}Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md new file mode 100644 index 0000000000..be1bbb7069 --- /dev/null +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -0,0 +1,47 @@ +- banner: + - navigation "Session hierarchy": + - button "Math rendering" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Render this mathematical proof. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Math rendering" [level=2] +- paragraph: + - text: Inline dollar + - math: θ + - text: and backslash + - math: 1 5 + - text: . +- math: π 4 < θ < π 2 +- math: θ ∈ ( π 4 , π 2 ) . (1) +- table: + - rowgroup: + - row "Symbol Value": + - columnheader "Symbol" + - columnheader "Value" + - rowgroup: + - row: + - cell: + - math: θ + - cell: + - math: 1 5 +- paragraph: MATH_RENDERING_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 0554b95404..81c2796e5a 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -20,7 +20,7 @@ - img - button "Branch into a new conversation" [disabled]: - img -- text: Available only on the last message of a completed turn 7/25 {{clock}}Ran for {{duration}} +- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - button "Read a.txt": - img - img @@ -46,13 +46,13 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}}Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] -- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok +- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 251352ee00..2ff2ae3d6f 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 @@ -17,4 +18,6 @@ - text: minimax-cn - button "编辑" - button "删除" - - button "+ 添加提供方" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index ffea707bd0..161b472e57 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 3572409fc0..a9b5dbb982 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -5,7 +5,7 @@ - img - searchbox "Search trajectory" - region "Trajectory timeline": - - tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s" + - tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms" - table: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md new file mode 100644 index 0000000000..f0177144c6 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -0,0 +1,71 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - button "编辑" + - text: DeepSeek deepseek-official API 密钥 + - textbox "API 密钥": + - /placeholder: 已配置——输入新值可替换 + - group: + - text: 自定义设置 API 地址 + - textbox "API 地址": + - /placeholder: https://api.deepseek.com + - text: 推理强度 + - combobox "推理强度": + - option "默认" [selected] + - option "off" + - option "high" + - option "max" + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: deepseek-v4-pro + - textbox "显示名称 1": + - /placeholder: 显示名称 + - text: DeepSeek-V4-Pro + - button "容量 1": + - img + - button "删除模型 1": + - img + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: private-preview + - textbox "显示名称 2": + - /placeholder: 显示名称 + - text: Private Preview + - button "容量 2" [expanded]: + - img + - button "删除模型 2": + - img + - text: 上下文窗口 + - textbox "上下文窗口 2": + - /placeholder: 1M + - text: "131072" + - text: 最大输出 token 数 + - textbox "最大输出 token 数 2": + - /placeholder: 256K + - text: 64K + - button "添加模型": + - img + - text: 添加模型 + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 2120c32408..f0c7d718e0 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,16 +5,16 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img - img @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -44,5 +44,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "4% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 4% of 128K Cache hit 51% Input 10.2K tok · Output 346 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 10.2K tok · Output 346 tok diff --git a/apps/web/tests/snapshots/plan-review/sidebar.expected.md b/apps/web/tests/snapshots/plan-review/sidebar.expected.md new file mode 100644 index 0000000000..0e184c2f6f --- /dev/null +++ b/apps/web/tests/snapshots/plan-review/sidebar.expected.md @@ -0,0 +1 @@ +- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]' diff --git a/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl b/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl new file mode 100644 index 0000000000..6fb863aec0 --- /dev/null +++ b/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl @@ -0,0 +1,19 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747} +{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}} +{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}} +{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}} +{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}} +{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md b/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md new file mode 100644 index 0000000000..cc6c8e01fd --- /dev/null +++ b/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md @@ -0,0 +1,3 @@ +- text: Failed {{workspace}} Get-Item missing.txt exit code 1 +- button "Copy" +- text: "[stderr] Get-Item : Cannot find path 'missing.txt' because it does not exist." diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a54b7dcd75..82e0b468c1 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -39,5 +39,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "3% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 3% of 128K Cache hit 95% Input 8.6K tok · Output 180 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 95% Input 8.6K tok · Output 180 tok diff --git a/apps/web/tests/snapshots/question-composer/sidebar.expected.md b/apps/web/tests/snapshots/question-composer/sidebar.expected.md new file mode 100644 index 0000000000..fcc2849e0f --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/sidebar.expected.md @@ -0,0 +1 @@ +- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected] diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index bd44e33ad0..cdde5d8790 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8df2ea2940..8bfd2f964d 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" [disabled] [expanded] @@ -30,6 +30,7 @@ - textbox "Edit queued message": Edited queue item - button "Save queued message": - img + - tooltip "Save queued message" - button "Cancel editing": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 08aea1a327..7370a15264 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -8,22 +8,18 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear" -- button "Context injection": +- button "Context injection goal": - img - img - - text: Context injection -- button "Context injection": + - text: Context injection goal +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection -- button "Context injection": - - img - - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - region "To-dos": - - button "To-dos 1/2 tasks · 1 in progress" + - button "To-dos 1 completed · 1 in progress" - img - text: Ongoing Goal Keep the composer context panels aligned - button "Pause goal": diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 2ce0077a2e..e8b65fdea1 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -10,32 +10,35 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} Edited queue item {{clock}} -- button "Copy": - - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn -- paragraph: partial -- status: Deep diving... +- text: {{clock}} Ran for {{duration}} +- button "2 queued messages" [expanded] - list: + - listitem: + - text: Edited queue item + - button "Edit queued message": + - img + - tooltip "Edit queued message" + - button "Remove queued message": + - img + - button "Steer queued message" [disabled]: + - img - listitem: - text: Queue item preserved after stop - button "Edit queued message": - img - button "Remove queued message": - img - - button "Steer queued message": + - button "Steer queued message" [disabled]: - img - textbox "Message the agent" - button "Commands": @@ -44,5 +47,5 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img -- button "Stop generating" +- button "Send message" [disabled] - text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 7afdf3c8b4..48b714a88c 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - list: @@ -21,6 +21,7 @@ - text: Edited queue item - button "Edit queued message": - img + - tooltip "Edit queued message" - button "Remove queued message": - img - button "Steer queued message": diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt index ce95519f0b..fbb20677df 100644 --- a/apps/web/tests/snapshots/search-card/grep-card.expected.txt +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -7,7 +7,7 @@ line=138: export function SearchBlock(props: SearchBlockProps) { line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set()) line=35: const search = searchCardModel(block) line=52: search={search} -line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) +line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) expand=… 其余 4 行 recovery=Found 9 of 42 matches @@ -22,6 +22,6 @@ packages/client/ui-conversation/src/client/toolviews/search-row.tsx Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { Line 35: const search = searchCardModel(block) Line 52: search={search} -Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) +Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) (Full grep result stored at: fixture://spill/grep-66. Read it to see every match.) \ No newline at end of file diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index d638821c45..467a4364b8 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -33,22 +33,22 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}}Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection AGENTS.md": - img - img - - text: Context injection + - text: Context injection AGENTS.md - img - text: permission preset read-only - textbox "Message the agent" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 9e78cca0f8..55fcb89ec8 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -33,20 +33,20 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}}Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection AGENTS.md": - img - img - - text: Context injection + - text: Context injection AGENTS.md - textbox "Message the agent" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index cb693b62a8..f358ff26f5 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 diff --git a/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md b/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md index d4fd339416..e58a41d358 100644 --- a/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md +++ b/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md @@ -28,8 +28,8 @@ - scrollbar-width: auto - scrollbar-color: auto - ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover) -- --dsh-scrollbar-thumb, pointer over the list: rgb(60, 60, 61) -- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87) +- --dsh-scrollbar-thumb, pointer over the list: rgb(84, 85, 87) +- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107) - list overflows: true - reserved band: 8px - scrollbar inset from the sidebar edge: 2px diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5b316f4efb..c32cee0077 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +24,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection: include the word BANANA in your final reply." +- text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/session.jsonl b/apps/web/tests/snapshots/steering/session.jsonl index ae41282be0..ce40ba4225 100644 --- a/apps/web/tests/snapshots/steering/session.jsonl +++ b/apps/web/tests/snapshots/steering/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}} {"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} -{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":91,"time":1785004181867,"data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}} {"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 00f1b33206..77385c6333 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -22,7 +22,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: @@ -37,7 +37,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -45,5 +45,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "6% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 98% Input 15.8K tok · Output 156 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 156 tok diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 561277b8ee..a01eea56d8 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -15,10 +15,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Branch into a new conversation" [disabled]: @@ -43,10 +43,11 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write +- button "6% of context used" - button "Send message" [disabled] -- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok +- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 45098769f7..1e2dcf9eca 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Search DeepSeek Harness snapshot search": - img - img @@ -23,7 +23,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s - textbox "Message the agent" - button "Commands": - img @@ -31,5 +31,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img +- button "0% of context used" - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Context 0% of 128K Cache hit 0% Input 22 tok · Output 7 tok +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 22 tok · Output 7 tok diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 1ea43f99db..6c96f9b6aa 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -21,7 +21,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // Two goldens pin the transient Host projection and its durable handoff: the // mid-turn state renders accepted steering from session/queue while the // question blocks admission, then the settled state renders the same message -// from steering/message beside the reply that obeys it. +// from user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() @@ -45,6 +45,12 @@ function assistantText(events: SessionEvent[]): string { .join('') } +/** Claimed user messages whose payload contains the exact scenario text. */ +function claimedMessages(events: readonly SessionEvent[], text: string): SessionEvent<'user/message'>[] { + return events.filter((event): event is SessionEvent<'user/message'> => + event.type === 'user/message' && JSON.stringify(event.data.content).includes(text)) +} + describe('web e2e: mid-turn steering lands durably and visibly', () => { let scaffold: WebScaffold let browser: Browser @@ -74,8 +80,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) if (MODE !== 'record') { - // The steer must NOT be a user/message — it lands as steering/message. - expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + // The steer lands as a durable user/message, so the inventory holds + // both the opening prompt and the later same-turn steer. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER]) } const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) @@ -112,7 +119,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { } // Answer the composer; the tool result closes the step, the loop drains - // the steer as steering/message, and the steered continuation runs the + // the steer as user/message, and the steered continuation runs the // final model call. await composer.getByRole('radio', { name: 'Yes' }).click() await composer.getByRole('radio', { name: 'Yes' }).press('Enter') @@ -124,15 +131,14 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Fixture honesty: a recording where the live model ignored the steer // would replay as a vacuous scenario — reject it and re-record instead. const recorded = parseSessionLog(await readFile(FIXTURE, 'utf8')) - expect(recorded.filter(e => e.type === 'steering/message')).toHaveLength(1) + expect(claimedMessages(recorded, STEER)).toHaveLength(1) expect(assistantText(recorded)).toContain('BANANA') return } - // Durable: exactly one steering/message, inside turn 1, carrying the text. - const steerEvents = sessionEvents.filter(e => e.type === 'steering/message') + // Durable: exactly one claimed user/message carrying the steering text. + const steerEvents = claimedMessages(sessionEvents, STEER) expect(steerEvents).toHaveLength(1) - expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1) expect(JSON.stringify(steerEvents[0])).toContain('BANANA') const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') expect(turnEnds).toHaveLength(1) @@ -182,7 +188,7 @@ describe('web e2e: composer shortcut steers directly', () => { it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering')) - expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER]) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(30_000) @@ -203,9 +209,8 @@ describe('web e2e: composer shortcut steers directly', () => { await composer.getByRole('radio', { name: 'Yes' }).press('Enter') await settled - const steerEvents = sessionEvents.filter(event => event.type === 'steering/message') + const steerEvents = claimedMessages(sessionEvents, STEER) expect(steerEvents).toHaveLength(1) - expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1) await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) expect(await pendingSteering.count()).toBe(0) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }) @@ -259,7 +264,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText }) await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 }) expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0) - expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0) + expect(claimedMessages(sessionEvents, queuedText)).toHaveLength(0) // Remove the asserted Queue row, then finish the recorded question turn // so replay teardown still proves that every fixture call was consumed. diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index e42628c15b..1b7b67aab3 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -88,6 +88,28 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo .waitFor({ timeout: 15_000 }) } +/** + * {@link connectFreshWorkspace} over the product default Chinese locale: the + * English helper's anchors assume the locale every other scenario boots, so a + * scenario that deliberately keeps zh needs the localized picker copy. + * @param page - the browser page under test. + * @param root - workspace parent directory. + * @param name - directory created under `root` and connected. + */ +export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> { + mkdirSync(join(root, name), { recursive: true }) + await page.getByRole('button', { name: '选择工作区' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + const pathInput = dialog.getByRole('textbox', { name: '编辑路径' }) + await pathInput.fill(join(root, name)) + await pathInput.press('Enter') + await dialog.getByRole('button', { name: '打开', exact: true }).click() + await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') + .waitFor({ timeout: 15_000 }) +} + /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */ export async function saveFailureShot(page: Page, name: string): Promise<void> { const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts new file mode 100644 index 0000000000..287d38c29d --- /dev/null +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -0,0 +1,309 @@ +// Browser contract for the tail-paged, virtualized Trajectory ledger. The +// scenario proves that semantic row identity survives an older-page prepend, +// DOM mounting stays bounded, and every scroll range remains reachable. +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SESSION_ID = 'trajectory-virtualization-e2e' +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'TRAJECTORY_VIRTUAL', + title: 'TRAJECTORY_VIRTUAL long ledger', + turns: 88, +}) +const MAX_MOUNTED_ROWS = 160 +const GEOMETRY_TOLERANCE = 2 +const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED' +const STREAM_TEXT = Array.from( + { length: 80 }, + (_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `, +).join('') + STREAM_MARKER + +const STREAM_CHUNKS: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...Array.from({ length: 80 }, (_, index): StreamChunk => ({ + type: 'text-delta', + index: 0, + text: `stream fragment ${String(index + 1).padStart(2, '0')} `, + })), + { type: 'text-delta', index: 0, text: STREAM_MARKER }, + { type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } }, + { type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } }, + { type: 'finish', reason: { kind: 'stop' } }, +] + +interface ScrollGeometry { + readonly clientHeight: number + readonly scrollHeight: number + readonly scrollTop: number +} + +interface RowAnchor { + readonly key: string + readonly top: number +} + +async function openSeed(page: Page): Promise<void> { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1) + await result.click() + await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }) + .last() + .waitFor({ timeout: 30_000 }) +} + +async function openTrajectory(page: Page): Promise<void> { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const pane = page.locator('[data-trajectory-scroll]') + await pane.waitFor({ timeout: 30_000 }) + await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]') + .waitFor({ timeout: 30_000 }) +} + +async function logicalRows(page: Page): Promise<number> { + const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount') + if (raw === null || !/^\d+$/.test(raw)) { + throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`) + } + return Number(raw) +} + +async function mountedRows(page: Page): Promise<number> { + return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count() +} + +async function geometry(page: Page): Promise<ScrollGeometry> { + return page.locator('[data-trajectory-scroll]').evaluate(host => ({ + clientHeight: host.clientHeight, + scrollHeight: host.scrollHeight, + scrollTop: host.scrollTop, + })) +} + +async function nextPaint(page: Page): Promise<void> { + await page.evaluate(() => new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })) + })) +} + +async function scrollToRatio(page: Page, ratio: number): Promise<void> { + await page.locator('[data-trajectory-scroll]').evaluate((host, value) => { + const maximum = Math.max(0, host.scrollHeight - host.clientHeight) + host.scrollTop = Math.round(maximum * value) + host.dispatchEvent(new Event('scroll')) + }, ratio) + await nextPaint(page) +} + +async function firstVisibleRow(page: Page): Promise<RowAnchor> { + return page.locator('[data-trajectory-scroll]').evaluate((host) => { + const hostBox = host.getBoundingClientRect() + const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')] + const row = rows.find((candidate) => { + const box = candidate.getBoundingClientRect() + return candidate.dataset.requestOnly !== 'true' + && box.bottom > hostBox.top + && box.top < hostBox.bottom + }) + const key = row?.dataset.trajectoryRowKey + if (row === undefined || key === undefined) { + throw new Error('trajectory scrollport has no visible semantic row') + } + return { key, top: row.getBoundingClientRect().top - hostBox.top } + }) +} + +async function rowTop(page: Page, key: string): Promise<number | null> { + return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => { + const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')] + const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey) + return row === undefined + ? null + : row.getBoundingClientRect().top - host.getBoundingClientRect().top + }, key) +} + +async function loadToFirstTurn(page: Page): Promise<void> { + const marker = FIXTURE.markers.user(1) + for (let attempt = 0; attempt < 12; attempt += 1) { + await scrollToRatio(page, 0) + if (await page.getByText(marker, { exact: false }).count() > 0) return + const before = await logicalRows(page) + await expect.poll(async () => ({ + marker: await page.getByText(marker, { exact: false }).count() > 0, + rows: await logicalRows(page), + }), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before }) + } + throw new Error('trajectory did not reach the first turn after twelve older-page requests') +} + +describe('web e2e: Trajectory virtualization over tail-paged history', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + let replayDir: string + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-')) + const replayFixture = join(replayDir, 'session.jsonl') + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayFixture, FIXTURE.log) + await writeFile(replayOverride, JSON.stringify([{ + kind: 'chunks', + chunks: STREAM_CHUNKS, + } satisfies ReplayEntry])) + scaffold = await launchWebScaffold({ + paceMs: 10, + replayFixture, + replayOverride, + }) + await seedSession(scaffold, FIXTURE.log, SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await rm(replayDir, { recursive: true, force: true }) + }) + + it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization')) + await openSeed(page) + + let held = false + let releaseHistory: () => void = () => {} + let finishHeldRequest: () => void = () => {} + const gate = new Promise<void>((resolve) => { releaseHistory = resolve }) + const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve }) + await page.route('**/api/session.history', async (route) => { + const request = route.request().postDataJSON() as { + method?: string + payload?: { beforeSeq?: number } + } + if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + held = true + await gate + try { + await route.continue() + } finally { + finishHeldRequest() + } + return + } + await route.continue() + }) + + try { + await openTrajectory(page) + const initialRows = await logicalRows(page) + expect(initialRows).toBeGreaterThan(0) + expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0) + expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + + await scrollToRatio(page, 0) + await expect.poll(() => held, { timeout: 15_000 }).toBe(true) + const anchor = await firstVisibleRow(page) + const selectedRow = page.locator( + `[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`, + ) + await selectedRow.click() + await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 }) + .toBe('true') + + releaseHistory() + await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows) + await nextPaint(page) + await expect.poll(async () => { + const top = await rowTop(page, anchor.key) + return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top) + }, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 }) + .toBe('true') + expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + + await loadToFirstTurn(page) + await expect.poll( + () => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(), + { timeout: 10_000 }, + ).toBeGreaterThan(0) + const fullRows = await logicalRows(page) + + await scrollToRatio(page, 0.5) + const middle = await geometry(page) + const maximum = middle.scrollHeight - middle.clientHeight + expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25) + expect(middle.scrollTop).toBeLessThan(maximum * 0.75) + expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + expect(await mountedRows(page)).toBeLessThan(fullRows) + + await scrollToRatio(page, 1) + await expect.poll(async () => { + const value = await geometry(page) + return value.scrollHeight - value.clientHeight - value.scrollTop + }, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expect.poll( + () => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(), + { timeout: 10_000 }, + ).toBeGreaterThan(0) + expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + + const trajectoryScroll = page.locator('[data-trajectory-scroll]') + await trajectoryScroll.evaluate((host) => { + const measuredWindow = window as Window & { __trajectoryScrollCalls?: number } + measuredWindow.__trajectoryScrollCalls = 0 + const original = host.scrollTo.bind(host) + const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => { + measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1 + Reflect.apply(original, host, args) + } + host.scrollTo = trackedScrollTo as typeof host.scrollTo + }) + const settled = scaffold.whenTurnSettled() + const input = page.locator('textarea').first() + await input.fill('Stream one deterministic response while Trajectory remains visible.') + await input.press('Enter') + await settled + await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 }) + await nextPaint(page) + const streamingScrollCalls = await trajectoryScroll.evaluate(() => { + return (window as Window & { __trajectoryScrollCalls?: number }) + .__trajectoryScrollCalls ?? 0 + }) + expect(streamingScrollCalls).toBeLessThanOrEqual(5) + expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + expect({ + pageErrors: tripwire.pageErrors, + warnings: tripwire.warnings, + }).toEqual({ pageErrors: [], warnings: [] }) + } finally { + releaseHistory() + if (held) await heldRequestFinished + await page.unroute('**/api/session.history') + } + }, 180_000) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b25320f488..a99336fd90 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff { timeout: 10_000 }, ).not.toBeUndefined() // First adoption births a blank Session+Agent whose workspace attach must - // settle before a test may delete the registration; the reuse path (same - // canonical cwd already has a blank session) creates no agent, so callers - // opt in only where a fresh attach is possible. + // settle before a test may delete the registration; re-registration after + // a delete mints a fresh blank Session+Agent too (the old cwd-only reuse + // path is gone), so callers opt in only where a fresh attach is possible. if (options.waitForAgent === true) { await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) .toBeGreaterThan(agentsBefore) @@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) // Re-registering the exact deleted path immediately, without a reload, is - // a supported reversible flow. It creates a fresh Workspace id without - // re-adopting the retained Session. + // a supported reversible flow. It creates a fresh Workspace id and does + // NOT re-adopt the retained (non-blank) Session; the New Session flow + // mints a fresh blank session and attaches it to the new registration + // (the old cwd-only blank reuse is gone, so the account is never empty). await adoptDirectory(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), @@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) expect(reregistered?.id).toBeDefined() expect(reregistered?.id).not.toBe(workspace.id) - expect(reregistered?.sessionIds).toEqual([]) + await expect.poll( + () => reregistered?.sessionIds ?? [], + { timeout: 10_000 }, + ).not.toEqual([]) + expect(reregistered?.sessionIds).not.toContain(SEED_ID) await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) .toBeGreaterThanOrEqual(1) expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..dd5fe879e7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -31,6 +31,8 @@ "tests/plan-review.e2e.ts", "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", + "tests/chat-scroll-fixture.ts", + "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/settings-chrome.e2e.ts", @@ -48,6 +50,9 @@ "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/math-rendering.e2e.ts", + "tests/markdown-cjk-strong.e2e.ts", + "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", @@ -61,7 +66,9 @@ "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", - "tests/complex-history.perf.ts" + "tests/composer-tab-geometry.e2e.ts", + "tests/complex-history.perf.ts", + "tests/pwsh-terminal.e2e.ts" ], "references": [ { diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fb3b43e56c..cd4e8aaaa5 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,10 +1,20 @@ # AGENTS.md — The documentation standard -This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers Agent Note](../.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale. +This file defines document structure, Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers Agent Note](../.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale. + +## Document structure + +These rules apply to human-facing documentation; [Agent Notes](../.agents/notes/README.md) remain outside their scope. A [postmortem](postmortem/README.md) is an incident-scoped reference; chronology records evidence, not a teaching sequence. A document's subject and tree position fix its scope: describe its own subject at appropriate detail, describe direct children only by purpose, responsibility, and high-level behavior, and link to the owning descendant for lower-level detail. Document type does not widen that scope. A reference may be exhaustive only about its own subject. Testing mechanisms, fixtures, and harnesses belong at the lowest owning level; higher documents link there. + +Classify every in-scope document as a tutorial or reference. A tutorial follows an ordered path to an outcome and introduces only what each step needs. A reference defines a lookup scope and describes current behavior without depending on a teaching sequence. Separate substantial tutorial and reference content; use a clear structural boundary when either part is small. + +Before writing a tutorial, privately classify the reader's starting knowledge and each concept as beginner, intermediate, or advanced. Establish prerequisites before dependent concepts, increase difficulty gradually, and move unnecessary advanced material to a later tutorial or reference. + +Author in this order: locate the document in the tree; set its permitted detail; choose tutorial or reference; for a tutorial, order concepts by prerequisite and difficulty; relocate descendant-owned detail; replace lower-level explanations with links to their owners. ## The tier taxonomy: one home per fact -Each fact has one home: the tier whose job it is. Elsewhere, link to that home; `verify-md-links` keeps links resolving while duplicated prose drifts. +Each fact has one home: the tier whose job it is. Elsewhere, link to that home. | Tier | Job | Does NOT belong there | |---|---|---| @@ -12,17 +22,15 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | | [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | | [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) | -| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and concise verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | +| [Agent Notes](../.agents/notes/README.md) | Decision records under their own lifecycle contract | Migration plans, checklists, and spec-speak once implemented; archived notes are frozen history | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) | | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | -| Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | -| [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts | +| Package README | Per-package config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc or catalog restatement, other packages' concerns | +| [development.md](development.md) | Contributor onboarding: setup, daily workflow, and CI shape at summary level | Runtime rationale (→ Agent Notes), drifting gate inventories | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [Cordis core API](cordis-catalog/core/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. - ## Writing rules - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems. @@ -31,7 +39,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). -- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. +- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, timing, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams". ## Wordcount Budgets @@ -44,20 +52,18 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the contract still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers. ## The slop checklist Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill runs this list as an audit: - The same rule stated in more than one home. Grep a distinctive phrase; keep one home, convert the rest to links. -- Narrated history: "previously", "now", "no longer", "used to", "renamed", "was moved", references to PRs or commits. State the current fact; the why belongs in an Agent Note, the story in a postmortem or git. -- A war story told inline where a one-line rule plus a postmortem/Agent Note link would do. +- Narrated history or war stories: "previously", "now", "no longer", "used to", "renamed", "was moved", PRs, or commits. State the current fact; link an Agent Note or postmortem when needed. - Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it. -- Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead. -- Hand-maintained inventories of tests, packages, or implementation status when the tree or a generator is authoritative. +- Hand-restated catalogs, JSDoc, or inventories of tests, packages, and status when source or a generator is authoritative. - Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it. -- The same rationale repeated beside sibling methods. State it once at the owning seam or shared helper. +- Rationale repeated beside sibling methods instead of once at the owning seam or helper. - Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home. - Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior. - Spec-speak in `implemented/` Agent Notes: "should", migration plans, acceptance checklists. An implemented Agent Note describes what is, per the [implemented-note instructions](../.agents/notes/implemented/AGENTS.md). diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b9d0d6cb33..5bf2c4454c 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -17,20 +17,22 @@ sequenceDiagram participant Session participant SDK as UI or SDK listener User->>Agent: followup(content) - Agent-->>SDK: <code>agent/inbox/enqueue</code> + Agent-->>SDK: <code>agent/inbox/spliced</code> + Agent-->>SDK: <code>agent/inbox/inserted</code> { message } Agent->>Driver: queued work wakes driver Driver-->>SDK: <code>agent/status</code> running - Note over Agent,Driver: next-step acceptance window opens - Driver->>Hooks: <code>agent/prompt-submit</code> waterfall - Hooks-->>Driver: authoritative allow, block, or add context - alt prompt blocked or admission failed - Driver-->>Driver: append context-only batch or keep steering boundary pending - else prompt allowed + Note over Agent,Driver: claim pending next-step input plus one queued prompt + Driver-->>SDK: <code>agent/inbox/spliced</code> pure deletion + Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message + Driver->>Hooks: <code>agent/pre-step</code> waterfall + Hooks-->>Driver: authoritative reject or enter(messages) + alt proposed step rejected or pre-step failed + Driver-->>Driver: claimed batch stays removed, no turn opens + else enter proposed step Driver->>Session: <code>turn/start</code> - Driver->>Session: <code>user/message</code> - Driver->>Prompt: <code>system-prompt/assemble</code> waterfall - Driver-->>Driver: <code>agent/step</code> serial checkpoint Driver->>Session: <code>step/start</code> + Driver->>Session: <code>user/message</code> per entered message + Driver->>Prompt: <code>system-prompt/assemble</code> waterfall Driver->>LLM: <code>agent/request</code> waterfall, then <code>llm/stream</code> waterfall LLM-->>Driver: StreamChunk* Driver->>Session: <code>assistant/chunk</code>* @@ -53,11 +55,17 @@ sequenceDiagram Driver->>Session: <code>tool/result</code> end end - Driver->>Session: post-tool context and steering (no prompt-submit) Driver->>Session: <code>step/end</code> - Driver->>Hooks: <code>agent/turn-stopping</code> serial terminal checkpoint + opt natural stop and next-step inbox empty + Driver->>Hooks: <code>agent/turn-stopping</code> serial terminal checkpoint + end + opt next-step input is pending + Driver-->>Driver: claim pending next-step input + Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message + Driver->>Hooks: <code>agent/pre-step</code> waterfall + Hooks-->>Driver: authoritative reject or enter(messages) + end end - Note over Agent,Driver: next-step acceptance window closes Driver->>Session: <code>turn/end</code> end Driver-->>SDK: <code>agent/status</code> idle @@ -65,9 +73,9 @@ sequenceDiagram The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. -`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. +`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. -The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint. +The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch. SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b5136c31df..b1d4bae895 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: ee50e249f8e588a3f92f57db929c6bbfb1c853dd -architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1 +architecture.md: 9b84c1482cb379fd796e21db45f128ba49750c54 +architecture.zh.md: 84708fcae24623e50b0157782cf459c35a55844b diff --git a/docs/architecture.md b/docs/architecture.md index ee50e249f8..9b84c1482c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,9 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, typed events, and disposable registrations. - -`packages/core/` groups the default agent flow; capabilities remain plugins. +Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, typed events, and disposable registrations. `packages/core/` groups the default flow; capabilities remain plugins. ### Default Services @@ -16,7 +14,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, |---|---|---| | — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registrations and shared layer storage (library) | | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | -| `ctx.systemPrompt` | `dsh-system-prompt` | ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables | +| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | @@ -38,7 +36,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers and Activation-based continuations | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | @@ -59,8 +57,8 @@ Events are the service extension API ([catalog](cordis-catalog/events.md), [prod ### Event Domains - **Session events** are durable log facts emitted through `session/event`. -- **Agent events** carry live `Agent` for status, prompt admission, request shaping, validation, and continuation. -- **Capability events** let owning seams attach policy and adapters without a loop import. +- **Agent events** carry live `Agent` for inbox, step, status, request, validation, and continuation. +- **Capability events** attach policy and adapters without a loop import. ### Interception Semantics @@ -68,80 +66,71 @@ Waterfalls are around-middleware: listeners delegate with `next()`; returning wi ## Default Loop Lifecycle -A **session** is append-only. An ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits its predecessor's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model or plugins stop it; a **step** is one model request plus tools. Quotes in the [sequence below](agent-lifecycle.md) mark durable events. +A **session** is append-only. A **turn** claims one queued follow-up, waits for its predecessor's checkpoint, and may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)); injection claims none. A **step** is one model request plus tools. Fresh creation and persisted resume first acquire an exact unpublished `SessionPreparation`; Agent and session publication happen only after private setup against that Session is ready ([decision](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)). Quotes in the [sequence](agent-lifecycle.md) mark durable events. -Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication; setup failure emits `agent-loop/config-start-failed`. ### Turn Flow ```text -choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit +choose declarative identity and acquire fresh/restored SessionPreparation + -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued occurrence - claim (edit/remove end) -> emit agent/status(running) if starting an interval - open the next-step acceptance window - -> agent/prompt-submit - blocked or failed prompt -> close the window without opening a turn - append a context-only caller batch immediately - keep steering and context staged beside it pending for a later admitted turn - allowed prompt: - 'turn/start' - append prompt + additional contexts as separate 'user/message' events - STEP loop: - agent/step - assemble system prompt and tools - materialize changed runtime context as sourced 'user/message' - drain injected context and provisional steering (steering bypasses prompt-submit) - snapshot the derived messages (the reconstruction boundary) + waking inbox insertion starts the driver before send returns + -> emit agent/status(running) if starting an interval + -> 'turn/start' + claim next-step input plus one next-turn message + -> emit agent/inbox/claimed({ message, turn }) for each claimed message + -> agent/pre-step(messages, { turn, step, signal }) + reject, empty input, cancellation, or listener failure + -> the claimed batch stays removed; close the no-step turn; stop the driver + enter -> step loop: 'step/start' - admit the drained steering receipts + append the returned batch as separate 'user/message' events + assemble ordered prompt and tool schemas -> snapshot derived messages agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: exclusive -> barrier - parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches - start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + parallel -> rolling pool, <= maxParallelToolCalls; reclassify at start + start -> 'tool/call' -> tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn and rejects pending steering - otherwise agent/turn-stopping -> drain context -> continue only for steering - close the next-step acceptance window - 'turn/end' -> agent/settled + tools owe another request or next-step inbox is nonempty + -> claim -> agent/pre-step -> append entered batch -> continue + otherwise agent/turn-stopping -> re-check the next-step inbox + 'turn/end' start the next waking queued message, or emit agent/status(idle) idle inject: - append 'user/message' - do not open a turn or run the model + queue non-waking next-step context + leave it pending until followup or steer wakes the driver ``` -Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites. -Before driver claim, `updateInbox()` may edit or remove a queued occurrence, or strictly transfer its immutable message into an open next-step window. That transfer ends the queued occurrence and accepts a new steering occurrence; a closed window leaves Queue unchanged. Direct `steer()` remains best-effort for newly submitted input and falls back to a waking follow-up outside the window ([decision](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md)). - -Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). +Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics. ### Failure Boundaries -Adapter failures close their step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and signal. A handled failure closes its turn and opens a retry turn from durable history without an idle notification; exhaustion leaves terminal `turn/end`. Failed chunks commit neither messages nor tool calls. +Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Turn and step events are turn-enclosed. Idle `user/message` and standalone `compact/* { turn: null }` consume no turn; their lock-time markers may interleave with injection. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer. +`ctx.agents` owns agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()` or its `followup()`, `steer()`, and `inject()` presets. `cancel()` and `whenIdle()` control lifecycle, while awaited disposal owns teardown. A follow-up `MessageId` follows durable inbox insertion, claiming, and discard notifications, not prompt output or turn ending; only an owner of a whole activity interval may summarize it as a run result ([decision](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ### Agent Scope -Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State @@ -149,11 +138,11 @@ Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, an The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream. -**Model-visible ⟺ logged**: before `step/start`, the loop appends the full current runtime-context snapshot as a sourced `user/message`, then snapshots derived messages. Those messages and the folded `request/header` reconstruct each request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` precedes requests and top-level tool dispatch, and follows `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` needs eager persistence and lifecycle drains; manual compaction flushes its bracket before releasing admission. Title work never delays responses; latest wins with provenance. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` needs eager persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; latest wins with provenance. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content @@ -169,7 +158,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` composes its baseline on the first `agent/pre-step` and folds it into the final entering batch right after the claimed prompt, so it reaches the first request with the direct prompt; rejection keeps it in the next-step inbox. Filesystem changes projected after tools are likewise folded into the next entering pre-step instead of creating a later context-only step ([decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)). `dsh-paths` owns shared paths. ### Bundles And Apps @@ -190,7 +179,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | -| Add model-facing context | call `agent.inject()` to append a sourced `user/message` without a turn | +| Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c2e596cd10..84708fcae2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -6,9 +6,7 @@ ## 概览 -每个 harness 都是 [Cordis](cordis-primer.md) 上下文;各包(package)贡献服务、类型化事件和可释放的注册项。 - -`packages/core/` 汇集默认的 agent(智能体)流程;各项功能仍以插件形式存在。 +每个 harness 都是 [Cordis](cordis-primer.md) 上下文;各包(package)贡献服务、类型化事件和可释放的注册项。`packages/core/` 汇集默认流程;各项功能仍以插件形式存在。 ### 默认服务 @@ -16,9 +14,9 @@ |---|---|---| | — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册项与共享层存储(库) | | `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 | -| `ctx.systemPrompt` | `dsh-system-prompt` | 有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量 | +| `ctx.systemPrompt` | `dsh-system-prompt` | 有序的提示词片段、工具 schema 和变量 | | `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | 活跃 agent、委托创建、`agent/*` 事件、进程内发起方作用域 | +| `ctx.agents` | `dsh-agent` | 活跃 agent(智能体)、委托创建、`agent/*` 事件、进程内发起方作用域 | | `ctx.agentLoop` | `dsh-agent-loop` | 实体 `Agent` 驱动器 | ### 功能服务 @@ -38,7 +36,7 @@ | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方和由 Activation 支撑的继续执行 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | @@ -59,8 +57,8 @@ ### 事件域 - **会话事件**是通过 `session/event` 发出的持久日志事实。 -- **Agent 事件**携带活跃 `Agent`,用于状态、提示词准入、请求塑形、验证和续跑。 -- **功能事件**让所属服务边界无需导入循环即可附加策略和适配器。 +- **Agent 事件**携带活跃 `Agent`,用于 inbox、步骤、状态、请求、验证和续跑。 +- **功能事件**无需导入循环即可附加策略和适配器。 ### 拦截语义 @@ -68,80 +66,71 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ## 默认循环生命周期 -**会话**采用仅追加方式。普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型或插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。[下文时序](agent-lifecycle.md)中的引号标记持久事件。 +**会话**采用仅追加方式。一个**轮次**领取一条已排队的后续消息,等待前一轮次的检查点,并可与其共用 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md));注入不领取输入。一个**步骤**包含一次模型请求及其工具。新建与持久化恢复会先取得精确的未发布 `SessionPreparation`;只有基于该 Session 的私有设置准备完毕后,系统才会发布 agent 与会话([决策](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md))。[时序](agent-lifecycle.md)中的引号标记持久事件。 -创建时若未提供 id,流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 +创建时若未提供 id,流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度;初始化失败会发出 `agent-loop/config-start-failed`。 ### 轮次流程 ```text -choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit +choose declarative identity and acquire fresh/restored SessionPreparation + -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued occurrence - claim (edit/remove end) -> emit agent/status(running) if starting an interval - open the next-step acceptance window - -> agent/prompt-submit - blocked or failed prompt -> close the window without opening a turn - append a context-only caller batch immediately - keep steering and context staged beside it pending for a later admitted turn - allowed prompt: - 'turn/start' - append prompt + additional contexts as separate 'user/message' events - STEP loop: - agent/step - assemble system prompt and tools - materialize changed runtime context as sourced 'user/message' - drain injected context and provisional steering (steering bypasses prompt-submit) - snapshot the derived messages (the reconstruction boundary) + waking inbox insertion starts the driver before send returns + -> emit agent/status(running) if starting an interval + -> 'turn/start' + claim next-step input plus one next-turn message + -> emit agent/inbox/claimed({ message, turn }) for each claimed message + -> agent/pre-step(messages, { turn, step, signal }) + reject, empty input, cancellation, or listener failure + -> the claimed batch stays removed; close the no-step turn; stop the driver + enter -> step loop: 'step/start' - admit the drained steering receipts + append the returned batch as separate 'user/message' events + assemble ordered prompt and tool schemas -> snapshot derived messages agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: exclusive -> barrier - parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches - start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + parallel -> rolling pool, <= maxParallelToolCalls; reclassify at start + start -> 'tool/call' -> tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn and rejects pending steering - otherwise agent/turn-stopping -> drain context -> continue only for steering - close the next-step acceptance window - 'turn/end' -> agent/settled + tools owe another request or next-step inbox is nonempty + -> claim -> agent/pre-step -> append entered batch -> continue + otherwise agent/turn-stopping -> re-check the next-step inbox + 'turn/end' start the next waking queued message, or emit agent/status(idle) idle inject: - append 'user/message' - do not open a turn or run the model + queue non-waking next-step context + leave it pending until followup or steer wakes the driver ``` -每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。 -驱动器认领之前,`updateInbox()` 可以编辑或移除 queued 单次入队项,也可以严格地把其不可变消息转移到开放的 next-step 窗口。该转移会结束 queued 单次入队项,并接受一个新的 steering 单次入队项;窗口关闭时 Queue 保持不变。直接调用 `steer()` 时,对新提交的输入仍采用尽力而为的语义,并在窗口之外回退为会唤醒 agent 的后续轮次([决策](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md))。 - -裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界,always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。 ### 失败边界 -适配器故障会先关闭自身步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和信号。已处理的失败会关闭所在轮次,并从持久历史开启重试轮次,不发出空闲通知;重试耗尽则留下终态 `turn/end`。失败分片既不提交消息,也不提交工具调用。 +适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -轮次和步骤事件均位于轮次边界内。空闲 `user/message` 与独立的 `compact/* { turn: null }` 不占用轮次;其锁定时刻标记可以与注入交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()` 或 `followup()`、带回执的 `steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()` 与 `whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程。 +`ctx.agents` 拥有 agent 并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用其 `followup()`、`steer()` 和 `inject()` 预设。`cancel()` 与 `whenIdle()` 控制生命周期,需等待完成的资源释放则负责拆卸。后续消息的 `MessageId` 跟踪持久 inbox 的插入、领取与丢弃通知,而不标识提示词输出或轮次结束;只有完整活动区间的所有方才能将其概括为一次运行结果([决策](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 ### Agent 作用域 -每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 @@ -149,11 +138,11 @@ idle inject: 会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。 -**模型可见 ⟺ 已记录**:在 `step/start` 之前,循环会将完整的当前运行时上下文快照作为一条带来源的 `user/message` 追加,随后对派生消息制作快照。这些消息与折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:在 `step/start` 进入的消息加上折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`request/context` 会在路由变化时另行记录与注册项绑定的提供方、模型及容量元数据;它不参与请求重建或 header 相等性判断。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 位于请求与顶层工具分发之前,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 需要尽快持久化与生命周期排空;手动压缩会在释放轮次接纳预留前 flush 其标记对。标题工作绝不延迟响应;最新标题按后写覆盖并携带来源信息。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 需要尽快持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新标题按后写覆盖并携带来源信息。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 @@ -169,7 +158,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/pre-step` 组合基线并将它折入最终进入的批次、紧随已领取的直接提示词之后,使其与直接提示词一同抵达第一次请求;reject 则将它留在 next-step inbox。工具执行后投影的文件系统变更也会折入下一次进入步骤的 pre-step,而不会另外创建稍后的纯上下文步骤([决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md))。`dsh-paths` 负责共享路径。 ### 组合包与应用 @@ -190,7 +179,7 @@ idle inject: | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | | 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成前包装 argv | | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | -| 添加模型可见上下文 | 调用 `agent.inject()`,追加带来源的 `user/message`,但不创建轮次 | +| 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3976c1fb24..cb24cee7d5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -105,6 +105,9 @@ flowchart LR pkg_subagent_acp["subagent-acp"] pkg_bash["bash"] svc_bash["ctx.bash<br/>Bash executor seam"] + pkg_pwsh_local["pwsh-local"] + pkg_tool_pwsh["tool-pwsh"] + pkg_bash_env["bash-env"] svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty<br/>Persistent PTY session registry"] @@ -167,6 +170,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash + pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime @@ -194,6 +198,7 @@ flowchart LR pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty pkg_pty_local --> svc_pty + pkg_pwsh_local --> svc_bash pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy @@ -231,7 +236,6 @@ flowchart LR pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction @@ -254,6 +258,9 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_bash --> pkg_tool_pwsh + svc_bashEnv --> pkg_tool_bash + svc_bashEnv --> pkg_tool_pwsh svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic @@ -371,8 +378,8 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | -| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | -| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | +| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d0de60ab9..ffa3d5bdd4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -192,7 +192,19 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) + +## `@deepseek-ai/dsh-bash-env` + +```ts config-catalog +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} +``` + +Source: [`packages/bash/bash-env/src/index.ts:29`](../packages/bash/bash-env/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -216,7 +228,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:40`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -235,7 +247,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) -Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:35`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-cli-demo` @@ -296,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -393,6 +405,8 @@ Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/c ## `@deepseek-ai/dsh-compact-tool-result-prune` +Requires: `tokenMeter` + ```ts config-catalog /** Character-budget policy for deterministic tool-result pruning. */ export interface ToolResultPruneConfig { @@ -467,7 +481,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:118`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:114`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -574,7 +588,7 @@ export interface Config { } ``` -Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:47`](../packages/host/webserver/src/index.ts) ## `@deepseek-ai/dsh-invariants` @@ -638,7 +652,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -660,6 +674,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } ``` @@ -765,7 +781,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:707`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:710`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -776,7 +792,7 @@ Requires: `agents` export type Config = Readonly<Record<string, never>> ``` -Source: [`packages/llm/llm-retry/src/index.ts:47`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -918,7 +934,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:68`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -963,6 +979,37 @@ export interface Config { Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +## `@deepseek-ai/dsh-pwsh-local` + +Requires: `subprocess` + +```ts config-catalog +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} +``` + +Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1019,17 +1066,18 @@ export interface Config { /** * Override the runner argv; bwrap-shaped profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and - * probing; a broken runner then fails at execution and must be identifiable by - * {@link runnerFailureSignatures}. + * probing. A runner that starts but refuses its profile must be identifiable by + * {@link runnerFailureSignatures}. Consumers classify spawn rejection; only + * attributable `ENOENT` or `EACCES` with runner argv[0] provenance becomes an + * infrastructure failure. */ runnerCommand?: string[] /** * Case-insensitive stderr substrings emitted when a configured * {@link runnerCommand} refuses its profile before executing the wrapped * command. Required and non-empty with `runnerCommand`; rejected without - * it. Missing/unexecutable runner errors are added automatically from - * `runnerCommand[0]`, while these signatures cover an executable runner's - * own failure dialect. + * it. Each entry is a non-empty, single-line, case-insensitive substring + * covering the executable runner's own failure dialect. */ runnerFailureSignatures?: string[] /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ @@ -1037,7 +1085,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:19`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -1089,13 +1137,15 @@ export interface Config { packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:58`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -1121,6 +1171,8 @@ export interface Config { * (network mounts). See {@link JournalMode}. */ journalMode?: JournalMode + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** @@ -1134,7 +1186,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:66`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` @@ -1620,7 +1672,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:171`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -1631,7 +1683,7 @@ Requires: `agents` export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */ + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number } ``` @@ -1663,19 +1715,17 @@ Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter ## `@deepseek-ai/dsh-tool-bash` -Requires: `tools` · `bash` · `systemPrompt` +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` ```ts config-catalog -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:34`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-bash-persistent` @@ -1813,6 +1863,20 @@ export interface Config { Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh` + +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` + +```ts config-catalog +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` @@ -1861,7 +1925,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 8621128e27..85c1af757b 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 2dd9165c4b5a7e04ecc7af0507f364fe89b294bb -adding-a-package.zh.md: 79f022531de500eed1d53b0915ee933b047121ff +adding-a-package.md: a45b222f6aed905a18ef9b480c989e6045029afe +adding-a-package.zh.md: af0e4d0779fa99ce43ebccba00c33eab16c4d362 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 2dd9165c4b..a45b222f6a 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -14,7 +14,6 @@ packages/<group>/<pkg>/ # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../../<group>/<dep> for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - tests/<x>.spec.ts README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section @@ -23,7 +22,7 @@ packages/<group>/<pkg>/ Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. @@ -33,11 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages/<group>/*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | | `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | -| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | +| `knip.json` | only if the package has entrypoints that repository discovery does not already cover | A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. -Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`. +Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology @@ -85,8 +84,7 @@ A package with no context effect or one consumer-owned path uses the audited `No pnpm install # registers the workspace pnpm run doc-sync pnpm run constraints && pnpm run typecheck && pnpm run lint -pnpm run test:coverage # 100% per-file over src (types.ts exempt) pnpm run build && pnpm run hygiene ``` -Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see [docs/testing.md](../testing.md). +Follow the [repository testing policy](../testing.md) for the behavior-specific checks and coverage required by the new package. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 79f022531d..af0e4d0779 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -14,7 +14,6 @@ packages/<group>/<pkg>/ # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../../<group>/<dep> for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - tests/<x>.spec.ts README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section @@ -23,7 +22,7 @@ packages/<group>/<pkg>/ 当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 -package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表要精确:`lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map` 和 `src`;不要发布 `lib/types` 下的 JS 或 JS-map 中间产物,也不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 @@ -33,11 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 | | `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | -| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) | +| `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 -以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`、`scripts/check-workspace-constraints.ts`。 +以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`.oxlintrc.json`、`scripts/check-workspace-constraints.ts`。 ## 3. 确定包拓扑 @@ -85,8 +84,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex pnpm install # registers the workspace pnpm run doc-sync pnpm run constraints && pnpm run typecheck && pnpm run lint -pnpm run test:coverage # 100% per-file over src (types.ts exempt) pnpm run build && pnpm run hygiene ``` -测试要求:每个注册表/注册操作都需要一个 HMR(热模块替换)安全测试(从子 fiber 注册,dispose(资源释放)它,断言清理完成)。鼓励编写充分的测试——见 [docs/testing.md](../testing.md)。 +请遵循[仓库测试政策](../testing.md),为新包运行行为所需的专项检查并达到相应覆盖率。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index e317b64965..ca64363bd6 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: 0688e4a46b1eb2ca282a7ee970546a34b3c1cc68 -adding-a-tool.zh.md: e12d5e23a93d08f15fb0d1ac40229c5c4ab26e38 +adding-a-tool.md: cb418a9118901cc6572fb17125351bdda922434b +adding-a-tool.zh.md: d8b73a51d1dde8647e7a032648f8a6344fcc83eb diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 0688e4a46b..cb418a9118 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -1,8 +1,8 @@ -# Cookbook: adding a tool +# Tool authoring reference English | [中文](adding-a-tool.zh.md) -How to give the model a new capability. The minimal shape below shows the contract; `packages/bash/tool-bash` is the production-grade three-package seam. +Reference for the contracts a model-facing tool must satisfy. For an ordered first tool, follow [Build a tool](../user/develop/basic/tool.md). `packages/bash/tool-bash` is the production-grade three-package example. ## The minimal shape @@ -35,7 +35,7 @@ export function apply(ctx: Context) { } ``` -Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically. +Registration is effect-based: disposing the plugin fiber unregisters the tool. Schemas flow into the system-prompt assembly automatically. ## Rules of the execute() contract @@ -89,6 +89,6 @@ Hard rules (they bite if broken): The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. -## Tests every tool needs +## Verification -Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For a UI card, assert the exact `presentCall` and `presentResult` views and exercise the owning host/client projection. Add an assembled snapshot for the shipped model or UI behavior the tool changes. +Follow the [repository testing policy](../testing.md) and the owning package's test documentation. A shipped model- or UI-visible change requires the assembled coverage specified there. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index e12d5e23a9..d8b73a51d1 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -1,8 +1,8 @@ -# 实操手册:添加工具 +# 工具编写参考 [English](adding-a-tool.md) | 中文 -如何为模型赋予一项新能力。下文的最小形态展示这项契约;`packages/bash/tool-bash` 是生产级、由三个包(package)构成的 seam。 +面向模型的工具必须满足哪些契约,均以本文为准。如需按步骤构建第一个工具,请阅读[构建工具](../user/develop/basic/tool.md)。`packages/bash/tool-bash` 是生产级的三包示例。 ## 最小形态 @@ -35,7 +35,7 @@ export function apply(ctx: Context) { } ``` -注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具(请编写 HMR(热模块替换)测试)。schema 会自动流入系统提示词的组装过程。 +注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具。schema 会自动流入系统提示词的组装过程。 ## execute() 契约的规则 @@ -89,6 +89,6 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 -## 每个工具必须的测试 +## 验证 -覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于 UI 卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并实际运行所属 host/client 投影。如果工具改变了已交付的模型或 UI 行为,请添加组装应用快照。 +遵循[仓库测试策略](../testing.md)和所属包的测试文档。已交付且面向模型或 UI 的变更必须提供其中规定的组装覆盖。 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index 448c548cc9..b8416c384f 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md -adding-a-vendored-package.md: a951a96f62d2ea3aa693a24d83bf46a1a12070cd -adding-a-vendored-package.zh.md: 878adbb203f8c79db0f127cb1ac58cd9e7a09171 +adding-a-vendored-package.md: b85d74a3a09b27254883b88cb8e6587e32ed811c +adding-a-vendored-package.zh.md: 2927837a28d1e7b593090d581522f69f84504806 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index a951a96f62..b85d74a3a0 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -53,7 +53,7 @@ Covered automatically by globs — no edits needed: root `package.json` workspac ```sh pnpm install # registers the workspace pnpm run typecheck -pnpm run build && pnpm run test && pnpm run constraints +pnpm run build && pnpm run constraints ``` -The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)). +Run the behavior checks selected by the [testing policy](../testing.md). The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)). diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 878adbb203..2927837a28 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -1,8 +1,8 @@ -# 实操手册:添加一个 vendored 包(package) +# 实操手册:添加一个 vendored 包 [English](adding-a-vendored-package.md) | 中文 -当 harness 需要引入另一个上游 Cordis 包(如 `@cordisjs/plugin-http`)时,应将其作为固定版本的源码 **vendor** 到 `vendor/` 下,而非作为 npm 依赖添加——原因见[vendoring 决策](../../.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md)。[vendor/README.md](../../vendor/README.md) 介绍如何*更新*已有的 vendored 包;本指南是添加**新** vendored 包的逐文件清单。(已对照现有 vendored 集合验证;如有偏差,请在此修正。) +当 harness 需要引入另一个上游 Cordis 包(如 `@cordisjs/plugin-http`)时,应将其作为固定版本的源码 **vendor** 到 `vendor/` 下,而非作为 NPM 依赖添加——原因见[vendoring 决策](../../.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md)。[vendor/README.md](../../vendor/README.md) 介绍如何*更新*已有的 vendored 包;本指南是添加**新** vendored 包的逐文件清单。(已对照现有 vendored 集合验证;如有偏差,请在此修正。) ## 1. 复制源码 @@ -53,7 +53,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 ```sh pnpm install # registers the workspace pnpm run typecheck -pnpm run build && pnpm run test && pnpm run constraints +pnpm run build && pnpm run constraints ``` -源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。 +请运行[测试政策](../testing.md)所选择的行为检查。源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。 diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index c37bf85c1d..13827c2b60 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-an-llm-adapter.md -adding-an-llm-adapter.md: a7f9dced70041653a0cb815147a07b6386d79e3e -adding-an-llm-adapter.zh.md: 3515927585201326b713bb03cd863886ce7846bd +adding-an-llm-adapter.md: 4fcc646ed2eea8a6170027d01761887aa28b0045 +adding-an-llm-adapter.zh.md: 35a671416f8160a6187a06f3dbd614dfe4faa778 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index a7f9dced70..4fcc646ed2 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -34,13 +34,10 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation. -## Structure that worked +## Implementation structure -Split the adapter into testable stages (llm-deepseek's layout): wire types (`types.ts`, coverage-exempt) → request serializer → SSE/transport parser → chunk-translation state machine → a thin adapter class wiring them. Each stage gets its own unit suite. +Keep wire types, request serialization, transport parsing, chunk translation, and the adapter class as separate responsibilities; [`llm-deepseek`](../../packages/llm/llm-deepseek/README.md) is the reference layout. -## Testing +## Verification -- **Unit: mock the provider, not the harness.** A scripted `node:http` server speaking the provider's wire format covers happy paths, every error status, malformed payloads, premature closes, and aborts — no network, and it drives the 100% per-file coverage gate. Works for SDK-backed adapters too (point the SDK's baseURL at the mock). -- **Hostile framing tests.** Split stream payloads at arbitrary byte positions (including mid-UTF-8) — real networks do. -- **E2E: `tests/*.e2e.ts`** under `pnpm run test:e2e`, gated with `describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green. Cover representative model/provider/API families and every provider mode you map, a tool-call round trip INCLUDING the follow-up turn with results in history, and loose assertions only (substring/structure, bounded maxTokens — real models are nondeterministic). -- Register the e2e file pattern in `knip.json` (per-workspace `entry` override) or knip flags it unused. +Follow the [repository testing policy](../testing.md), which owns adapter coverage, real-provider checks, and published-entry requirements. diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 3515927585..35a671416f 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -1,8 +1,8 @@ -# 实操手册:添加 LLM 适配器 +# 实操手册:添加 LLM(大语言模型)适配器 [English](adding-an-llm-adapter.md) | 中文 -如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE 由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 +如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE(Server-Sent Events)由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 ## 基本形态 @@ -20,7 +20,7 @@ export function apply(ctx: Context, config: Config) { } ``` -注册基于副作用(HMR 安全);每个提供方路由仅对应一个适配器,重复注册会抛出异常,多路由注册要么全部成功,要么全部失败。`options.provider` 用于选择适配器,`options.model` 是提供方模型 ID,因此动态模型目录适配器无需重新配置生命周期即可提供新模型。密钥采用 Cordis 原生方式管理:schemastery Config 带环境变量回退,通过 cordis.yml 的 `!!js process.env.MY_KEY` 注入。代码中禁止临时读取密钥文件。 +注册基于副作用,可安全支持 HMR(热模块替换);每个提供方路由仅对应一个适配器,重复注册会抛出异常,多路由注册要么全部成功,要么全部失败。`options.provider` 用于选择适配器,`options.model` 是提供方模型 ID,因此动态模型目录适配器无需重新配置生命周期即可提供新模型。密钥采用 Cordis 原生方式管理:schemastery Config 带环境变量回退,通过 cordis.yml 的 `!!js process.env.MY_KEY` 注入。切勿在代码中读取自行约定的密钥文件。 ## 协议义务(两个实现共同验证的契约) @@ -32,15 +32,12 @@ export function apply(ctx: Context, config: Config) { - 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 - 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。 -提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。 +提供方特有的思考模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;遵守解析模型时传入的可选 `AbortSignal`。推理(reasoning)强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。 -## 经验证有效的结构 +## 实现结构 -将适配器拆分为可测试的阶段(llm-deepseek 的布局):协议格式(wire format)类型(`types.ts`,豁免覆盖率)→ 请求序列化器 → SSE/传输解析器 → 分片转换状态机 → 一个将它们串联的薄适配器类。每个阶段配备独立的单元测试套件。 +让协议类型、请求序列化、传输解析、分片转换和适配器类分别承担独立职责;[`llm-deepseek`](../../packages/llm/llm-deepseek/README.md) 是参考布局。 -## 测试 +## 验证 -- **单元测试:mock 提供方,而非 harness。** 用脚本化的 `node:http` 服务器模拟提供方的协议格式,覆盖正常路径、所有错误状态码、畸形载荷、连接提前关闭和中止——无需网络,且能满足 100% 逐文件覆盖率门禁。对基于 SDK 的适配器同样适用(将 SDK 的 baseURL 指向 mock 服务器)。 -- **恶意分帧测试。** 在任意字节位置(包括 UTF-8 字符中间)切割流载荷——真实网络环境正是如此。 -- **E2E:`tests/*.e2e.ts`**,通过 `pnpm run test:e2e` 运行,以 `describe.skipIf(!process.env.MY_KEY)` 守卫,确保无密钥的 CI 保持绿色。覆盖具有代表性的模型/提供方/API 系列以及你映射的每种提供方模式、一次包含后续轮次(历史中带工具结果)的工具调用往返,以及仅做宽松断言(子串/结构匹配、有界的 maxTokens——真实模型是非确定性的)。 -- 在 `knip.json` 中注册 e2e 文件模式(per-workspace `entry` 覆盖),否则 knip 会将其标记为未使用。 +遵循[仓库测试策略](../testing.md),该策略负责适配器覆盖、真实提供方检查和已发布入口要求。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a4c20672c0..079bb5b3f1 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7 -extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe +extension-cookbook.md: 1d5705672396d66d5625567aefec5006ae126e67 +extension-cookbook.zh.md: 2f8e0ccb745baefb857f1065040f3225142d264f diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 07073c39f8..1d57056723 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -2,9 +2,7 @@ English | [中文](extension-cookbook.zh.md) -> FIXME: This important guide has not received sufficient human design review; complete that review before the first release. - -The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see [adding a package](adding-a-package.md), [adding a tool](adding-a-tool.md), and [adding an LLM adapter](adding-an-llm-adapter.md); for the seams these hook into see [docs/architecture.md](../architecture.md). +Reference shapes for the harness extension surface. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-seam map. ## A tool plugin @@ -64,7 +62,7 @@ export function apply(ctx: Context) { ## An external protocol driver -A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, maps the protocol's requests to `followup()` or `cancel()`, and settles each request exactly once from durable `turn/end`. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. +A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to `followup()` or `cancel()`. A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating `MessageId` with `turn/end`. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. [`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) owns the exact method and lifecycle contract. @@ -84,7 +82,8 @@ export function apply(ctx: Context) { } } }) - // Inbound "prompt": create/resume an agent and feed it; settle on turn end. + // Inbound "prompt": create/resume an agent, feed it, and return its enqueue receipt. + // Whole-agent status is a separate notification; no turn end belongs to this prompt. // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` @@ -101,12 +100,12 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | +| Hook system (user + project level) | listeners on `agent/session-start`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker | | Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/pre-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 10664af9a3..2f8e0ccb74 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -2,9 +2,7 @@ [English](extension-cookbook.md) | 中文 -> FIXME:这篇重要指南尚未经过充分的人工设计审查;请在首次发布前完成审查。 - -针对 harness 扩展表面编写的三种插件形态,以示意性代码片段呈现(省略了 import 和辅助桩——不可直接复制运行)。完整的分步指南见[添加包(package)](adding-a-package.md)、[添加工具](adding-a-tool.md)和[添加 LLM(大语言模型)适配器](adding-an-llm-adapter.md);这些插件所挂接的 seam 见 [docs/architecture.md](../architecture.md)。 +harness 扩展表面的参考形态。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展 seam 映射由[架构文档](../architecture.md)负责。 ## 工具插件 @@ -64,7 +62,7 @@ export function apply(ctx: Context) { ## 外部协议驱动 -*协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),将协议请求映射为 `followup()` 或 `cancel()`,并根据持久的 `turn/end` 对每个请求恰好结算一次。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 +*协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),并将协议请求映射为 `followup()` 或 `cancel()`。底层提示词请求返回其持久入队回执;它不会通过关联 `MessageId` 与 `turn/end` 获得结果。整个 agent 的状态应单独发布。自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 [`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 拥有精确的方法和生命周期契约。 @@ -84,7 +82,8 @@ export function apply(ctx: Context) { } } }) - // Inbound "prompt": create/resume an agent and feed it; settle on turn end. + // Inbound "prompt": create/resume an agent, feed it, and return its enqueue receipt. + // Whole-agent status is a separate notification; no turn end belongs to this prompt. // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` @@ -101,12 +100,12 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| -| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | +| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/pre-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml index b983ffd591..a069b8a4da 100644 --- a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml +++ b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cookbook/maintaining-dsh-code-review.md maintaining-dsh-code-review.md: 2b5d0d926ae922f2650daac33cf35991cb71c5e5 -maintaining-dsh-code-review.zh.md: c0e8b64fde3a67174878b4b0665712c9ba2e67c0 +maintaining-dsh-code-review.zh.md: 56d274c080fcd95b6d18219f4bf0e0e7485f624e diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md index c0e8b64fde..56d274c080 100644 --- a/docs/cookbook/maintaining-dsh-code-review.zh.md +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -1,8 +1,8 @@ -# 维护 dsh-code-review skill +# 维护 dsh-code-review skill(技能) [English](maintaining-dsh-code-review.md) | 中文 -[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill(技能)由一名指定操作员通过私有的周期维护工具持续更新。本实操手册(cookbook)既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note(agent 决策记录)](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。 +[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill 由一名指定操作员通过私有的周期维护工具持续更新。本实操手册既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。 ## 维护者会收到什么 @@ -11,7 +11,7 @@ 1. 选择指定窗口内合并、且合并 commit 可从 `origin/master` 到达的 PR(每天运行默认选择 2 个 UTC 日,每周运行选择 7 日)。合并 commit 无法到达的 PR(例如父分支被 squash 的堆叠分支),或超出 250 个 commit 获取上限的 PR,会记录到 `skipped-pulls.json` 并跳过,不会中止本次运行。 2. 收集合并前带 commit 锚点的人工评审反馈(行内评论和评审提交),然后比较反馈时与最终落地的 PR patch。它不获取 PR 会话评论,因为 GitHub 当前状态无法为这些评论提供可抵抗 force-push 的反馈时基线;它也不会把只存在于目标分支的变更作为采纳证据。 3. 两个独立配置的评审适配器先对来源和采纳情况分类,再根据当前 skill 对双方一致认定已采纳的条目分类。 -4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞发现,循环就会继续,直到双方批准。 +4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞性问题,循环就会继续,直到双方批准。 5. 工具声明成功前,会针对候选版本运行 `pnpm run doc-sync` 和 `pnpm run lint`。 每次运行都把产物保存在操作员的机器上。保存的 diff、候选 `SKILL.md` 和提升 manifest(元数据清单)按时间戳命名,存放在 `~/dsh-code-review-outputs/` 下。manifest 记录源 master commit 与 skill blob、源反馈 ID 和 URL、已落地证据范围、适配器裁决和门禁结果;每个适配器的原始 I/O 留在私有临时目录中,该目录路径会写入通知和 `~/Library/Logs/dsh-code-review-maintainer/` 下的每日日志。维护 worktree 在每次运行后都会恢复为干净状态,避免操作员直接在维护副本中编辑。 @@ -37,7 +37,7 @@ ```sh rm ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.{diff,SKILL.md,manifest.json} ``` - - **暂存成批。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。 + - **留待成批处理。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。 - **提升。** 在仓库的干净 `master` checkout 中运行提升辅助工具。它会刷新 `master`、验证当前 skill 与记录的源 blob 一致、应用保存的 diff,并创建一份 draft PR,其正文包含 manifest 的来源摘要。如果 skill 已发生漂移,它会停止而不是覆盖更新后的指导;操作员仍需在 GitHub 上评审 PR,并选择合并或关闭。 ```sh diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8f4385938c..5044b0e5ce 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -13,27 +13,6 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ## `agent/*` -### `agent/cancel-requested` — emit - -Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. - -```ts cordis-catalog -/** - * Effective broad cancellation was requested, before queued/outbox work - * is cleared or the active turn is aborted. This observe-only notification - * cannot veto cancellation; listener failures are contained. - * @param agent - the agent whose current work is being cancelled. - * @param cause - the explicit typed cancellation cause. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void -``` - -Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) - ### `agent/created` — emit A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. @@ -54,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,16 +53,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit -A step or turn errored. The machine reports a failure here (plus the logger) even when the error has no in-turn position for a durable record. +A step or turn errored. The machine reports a failure here even when the error has no in-turn position for a durable record. ```ts cordis-catalog /** - * A step or turn errored. The machine reports a failure here (plus the - * logger) even when the error has no in-turn position for a durable record. + * A step or turn errored. The machine reports a failure here even when + * the error has no in-turn position for a durable record. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. @@ -96,115 +75,87 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:467`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) -### `agent/inbox/dequeue` — emit +### `agent/inbox/claimed` — emit -The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message. +One message left the inbox inside its open turn. If the proposed step is rejected, the claimed message ends here: it is neither discarded nor re-emitted as a user/message, and the turn closes without a step. ```ts cordis-catalog /** - * The driver claimed one item out of the inbox: a queued item at a turn - * boundary, or steering drained between steps. Fires after the item leaves - * its FIFO and before it becomes a durable message. - * @param agent - the agent whose inbox item was claimed. - * @param item - the exact claimed occurrence. + * One message left the inbox inside its open turn. If the proposed step + * is rejected, the claimed message ends here: it is neither discarded nor + * re-emitted as a user/message, and the turn closes without a step. + * @param agent - the agent whose inbox changed. + * @param event - the claimed message and owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void +'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) -### `agent/inbox/discard` — emit +### `agent/inbox/discarded` — emit -Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item. +One message was discarded from the live inbox. ```ts cordis-catalog /** - * Pending inbox items were dropped without delivering them, so every - * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, - * emits this after `agent/cancel-requested` when applicable and before - * aborting the active work. Fires once per drop with every dropped item. - * @param agent - the agent whose inbox items were dropped. - * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. + * One message was discarded from the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItem[]): void +'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) -### `agent/inbox/enqueue` — emit +### `agent/inbox/inserted` — emit -An item entered the queued or steering inbox. `placement` is the acceptance-time routing result; listeners must not reconstruct it from later agent or session state. +One message entered the live inbox. ```ts cordis-catalog /** - * An item entered the queued or steering inbox. `placement` is the - * acceptance-time routing result; listeners must not reconstruct it from - * later agent or session state. - * @param agent - the owning agent. - * @param item - accepted occurrence, message, and resolved placement. + * One message entered the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void +'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) -### `agent/inbox/update` — emit +### `agent/pre-step` — waterfall -A still-pending queued item changed content. The item id, placement, and position remain stable while the event carries the replacement message. +Reject a proposed step or replace the messages that enter it. Calling `next()` preserves the current messages. ```ts cordis-catalog /** - * A still-pending queued item changed content. The item id, placement, and - * position remain stable while the event carries the replacement message. - * @param agent - the owning agent. - * @param item - the complete post-update occurrence. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void -``` - -Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) - -### `agent/prompt-submit` — waterfall - -Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn. Call `next()` for the unchanged default. The signal controls only this admission attempt; listeners may cooperate with it but must not retain it for a later attempt or turn. - -```ts cordis-catalog -/** - * Allow, rewrite, or block one claimed prompt before it becomes a user - * message or opens a turn. Call `next()` for the unchanged default. The - * signal controls only this admission attempt; listeners may cooperate with - * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose turn claimed the message. - * @param message - the frozen claimed message, including identity and source. - * @param signal - the current turn's explicit abort signal. + * Reject a proposed step or replace the messages that enter it. Calling + * `next()` preserves the current messages. + * @param agent - the agent proposing the step. + * @param messages - messages removed from the inbox for this step. + * @param context - proposed turn and step coordinates plus cancellation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision> +'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> ``` -Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:380`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,37 +179,30 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall -Handle a model-request failure after its failed step has closed but before the failed turn closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns the error, or calls `next()` to delegate. The default `undefined` leaves the failure terminal. +Handle one failed model-request attempt before the loop retries or closes its step. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery, or calls `next()` to delegate. The default `undefined` leaves the failure terminal. ```ts cordis-catalog /** - * Handle a model-request failure after its failed step has closed but - * before the failed turn closes. A listener returns `{ kind: 'retry' }` - * without calling `next()` when it owns the error, or calls `next()` to - * delegate. The default `undefined` leaves the failure terminal. + * Handle one failed model-request attempt before the loop retries or closes + * its step. A listener returns `{ kind: 'retry' }` without calling `next()` + * when it owns recovery, or calls `next()` to delegate. The default + * `undefined` leaves the failure terminal. * @param agent - the agent whose request failed. - * @param turn - the open turn number. - * @param step - the failed step number. - * @param error - the original model-request failure. - * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another - * retry turn in this consecutive sequence. - * @param retryPolicy - immutable policy of the adapter registration that served - * the failed request, or `undefined` if no final adapter served it. + * @param context - request coordinates, provider, normalized failure, and serving policy. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> +'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> ``` -Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,41 +224,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) - -### `agent/settled` — emit - -One drain chain reached its terminal turn: that turn's `turn/end` is already committed. Automatically recovered failed turns do not emit this notification, and neither does a run that aborts or fails before its `turn/start` commits — there is no durable turn to settle against. `reason` says why; model-request recovery is exhausted when an error reaches it. - -```ts cordis-catalog -/** - * One drain chain reached its terminal turn: that turn's `turn/end` is - * already committed. Automatically recovered failed turns do not emit this - * notification, and neither does a run that aborts or fails before its - * `turn/start` commits — there is no durable turn to settle against. - * `reason` says why; model-request recovery is exhausted when an error - * reaches it. - * @param agent - the agent whose turn closed. - * @param turn - the terminal turn number. - * @param reason - why the terminal turn ended, with live error facts when it failed. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:454`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` synchronously; drive lifecycle from this event. +Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` synchronously after reserving cancellation; `idle` means no driver remains scheduled or active. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`). `send()` does not enter - * `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`). A waking delivery enters + * `running` synchronously after reserving cancellation; `idle` means no + * driver remains scheduled or active. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -325,35 +245,11 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) - -### `agent/step` — serial - -Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation). The single "between steps" extension point: inject context, steer, or edit the session log here — the request's history derives from the log right after this settles. - -```ts cordis-catalog -/** - * Awaited serial checkpoint before EVERY request of a turn is built (the - * first as well as each post-tools continuation). The single "between - * steps" extension point: inject context, steer, or edit the session log - * here — the request's history derives from the log right after this settles. - * @param agent - the agent about to send a request. - * @param turn - the open turn number. - * @param step - the step number about to open. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ -'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:393`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial -The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step. +The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step. The conclusion never short-circuits already-submitted next-step work: same-step `additionalContexts` or racing steering still runs, and the turn closes only when that inbox drains. ```ts cordis-catalog /** @@ -363,7 +259,10 @@ The turn is about to close: the model owes no response (no live tool calls, no f * re-reads its inbox: fresh steering runs another step, none closes the * turn. Data decides, so listener order cannot change the outcome. The * inverse control (stop a tool loop early) is data too: a tool result - * carrying `concludesTurn` ends the turn at its step. + * carrying `concludesTurn` ends the turn at its step. The conclusion + * never short-circuits already-submitted next-step work: same-step + * `additionalContexts` or racing steering still runs, and the turn + * closes only when that inbox drains. * @param agent - the agent whose turn is at its stop boundary. * @param turn - the turn about to close. * @param signal - the current turn's explicit abort signal. @@ -375,7 +274,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -398,7 +297,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:157`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -550,13 +449,12 @@ Source: [`packages/fs/fs/src/index.ts:54`](../../packages/fs/fs/src/index.ts) ### `goal/changed` — emit -Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Goal mutation accepted by one live agent. The matching `goal/change` session event has already committed. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** - * Goal mutation accepted by one live agent. The matching context event is - * already appended or queued in that agent's active tool-batch FIFO. - * Listener failures are contained. + * Goal mutation accepted by one live agent. The matching `goal/change` + * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - agent whose session owns the goal. * @param change - fresh current projection or clear tombstone. @@ -567,7 +465,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts) ## `llm/*` @@ -588,7 +486,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) ### `llm/stream` — waterfall @@ -612,7 +510,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:58`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -637,7 +535,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -658,7 +556,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -681,7 +579,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -701,7 +599,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..b3d81d61b9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:252`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -220,9 +220,18 @@ Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/i ## `ctx.approval` — `ApprovalService` -Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the cache-safe runtime-context snapshot. +Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the runtime-context snapshot and switch notices. ```ts cordis-catalog +/** + * Switch one live agent's policy and queue the transition for its next model + * step. Session initialization uses {@link setApprovalPolicy} directly + * because there is no previously visible policy to change. + * @param agent - the live agent whose policy is changing. + * @param policy - the new effective policy. + */ +setPolicy(agent: Agent, policy: ApprovalPolicy): void + /** * Ask the composed answerers to decide one readonly same-process request. * The service borrows the request, agent, session, and live signal directly. @@ -251,7 +260,7 @@ async request(req: ApprovalRequest): Promise<ApprovalOutcome> overrideOf(session: Session): ApprovalPolicy | undefined ``` -Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) Source: [`packages/ui/user-approval/src/index.ts:193`](../../packages/ui/user-approval/src/index.ts) @@ -293,11 +302,11 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` -Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model shell call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog /** @@ -309,7 +318,7 @@ Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The names register(contributor: BashEnvContributor): () => void /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. + * Build the trusted `DSH_*` snapshot for one shell tool execution. * @param execution - the current tool execution. * @returns an immutable environment overlay containing built-ins and current contributions. */ @@ -324,7 +333,7 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) ## `ctx.clientModuleHost` — `ClientModuleHostService` @@ -465,21 +474,22 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger /** * Explicitly compact useful history even below automatic pressure thresholds. - * Implementations reserve idle turn admission synchronously before any - * asynchronous work, select a useful range without writing on a no-op, then + * Implementations synchronously start an idle task before any asynchronous + * work, select a useful range without writing on a no-op, then * append a standalone `compact/start` before summarization. That durable * marker is the compaction lock until one `compact/end` attempt. Later waking * prompts remain accepted in FIFO order and start only after the optional - * durability checkpoint and admission release. Context injected while the + * durability checkpoint and idle-task settlement. Context injected while the * summary runs may sit between the marker pair; only the selected span must * remain stable. * * @param agent - idle agent whose durable history should be compacted. - * @param signal - command-owned cancellation forwarded to summarization. + * @param signal - cancellation scoped to this compaction request. * @returns the compaction result, or `null` when no safe useful range exists. - * @throws {@link ManualCompactionError} for expected busy, changed-span, - * summarization/shrink, commit-stage, or persistence failures, and the exact - * abort reason when cancelled. Failed attempts remain visible in the log. + * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, + * changed-span, summarization/shrink, commit-stage, or persistence failures; + * an aborted request preserves its exact abort reason. Failed attempts remain + * visible in the log. */ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null> @@ -506,7 +516,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:80`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) ## `ctx.credentials` — `Credentials` (abstract seam) @@ -755,7 +765,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` @@ -770,6 +780,14 @@ The web-shape HTTP carrier service. Activation listens immediately (route regist */ register(route: WebRoute): () => void +/** + * Register an exact-path HTTP upgrade route. Duplicate paths throw because + * one socket can have only one protocol owner. + * @param route - pathname and handler owning negotiation plus socket use. + * @returns the disposer removing the route. + */ +registerUpgrade(route: WebUpgradeRoute): () => void + /** * Register an index.html transform, applied to every index response in * registration order. @@ -779,7 +797,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:63`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` @@ -885,15 +903,13 @@ async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<Ll async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> /** - * Stream one model call as raw chunks (token-level deltas). Throws - * `LlmError` with code `NO_ADAPTER` if no adapter is registered for - * `options.provider`. Replay state is retained only when the same adapter - * instance owns its historical provider and the target provider. Final - * adapter selection remains fixed through asynchronous exact-model resolution - * and dispatch. Selection, dispatch, and iteration failures retain their - * original Error identity and are tagged in a call-local scope for narrow - * agent-loop request recovery; middleware and nested-call failures remain - * untagged for the outer call. + * Stream one model call as raw chunks (token-level deltas). Replay state is + * retained only when the same adapter instance owns its historical provider + * and the target provider. Final adapter selection remains fixed through + * asynchronous exact-model resolution and dispatch. Adapter selection, + * dispatch, and iteration failures become terminal `error` or `aborted` + * finish chunks; middleware, nested-call, cleanup, and consumer failures + * remain thrown. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ @@ -990,7 +1006,7 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:182`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` @@ -1095,7 +1111,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` @@ -1159,46 +1175,59 @@ abstract create(meta: SessionHeader): Promise<void> abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void> /** - * Load a header and balanced contiguous log. A complete interrupted final - * turn is preserved and durably closed with missing tool errors plus any open - * step and turn boundaries; only a torn final record is discarded. Unknown - * versions and corruption in the committed prefix reject. Implementations - * MUST NOT crash-repair an identity still bound to a live Session: a balanced - * live log may return with its stored header as a durable snapshot, while an - * open live turn rejects. - * A coordinator-backed cold load reserves the identity across storage awaits, - * so concurrent publication of a same-id live Session rejects. - * Returned events are detached, and every identified message is deeply - * frozen. Coordinator-backed implementations upgrade supported pre-identity - * message events before validation; other malformed messages reject before - * any stored event is returned. + * Prepare the exact unpublished Session used by resume. Implementations may + * reuse object graphs retained by an earlier {@link inspect} after confirming + * their durable revision is still current; disposal releases an unpublished + * reservation. Revision retries require the durable log to remain unchanged + * for one read/check round trip; continuous external writers may delay completion. + * @param id - persisted session to prepare. + * @param signal - optional cancellation for preparation work. + * @returns one owned unpublished Session preparation. + */ +async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> + +/** + * Load an immutable balanced logical view and commit any required cold + * recovery. A complete interrupted final turn is preserved and durably + * closed with missing tool errors plus any open step and turn boundaries; + * only a torn final record is discarded. Unknown versions and corruption in + * the committed prefix reject. Implementations MUST NOT crash-repair an + * identity still bound to a live Session: a balanced live log may return as a + * durable snapshot, while an open live turn rejects. Returned values may be + * shared with immutable live or prepared state and must not be mutated. + * Revision-based implementations may wait for one stable read/check round trip. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ -abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract load(id: SessionId): Promise<SessionInspection> /** - * Inspect a header and its valid contiguous stored prefix without repairing - * a torn tail, closing an interrupted turn, or publishing coordinator state. - * This read is serialized with writes for the same id and returns detached - * values with upgraded, deeply frozen identified messages, so observers - * cannot mutate message identity/content or backend-owned state. Other - * malformed messages reject. + * Inspect an immutable logical session without committing recovery or + * publishing it. A cold complete interrupted turn receives synthetic closers + * in memory and a torn physical tail remains untouched. An already-live + * Session instead yields its current immutable snapshot, which may contain an + * open turn and its `session/end-seed` boundary. Coordinator-backed + * implementations retain the exact cold unpublished Session for bounded + * reuse by a later {@link prepare}. A stale ready source is reloaded; a source + * already committing or reserved for resume remains exclusive, and inspection + * may borrow its immutable view. Callers borrow only the immutable header and + * log. Continuous external writers may delay revision convergence. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. - * @returns the header and valid stored event prefix exactly as observed. + * @returns the validated header and current logical event log. */ -abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted - * projection cache folding only the tail past its checkpoint). Like - * {@link inspect} it is non-mutating and detached: no torn-tail truncation, - * no synthetic closers, no coordinator-state publication; only events from - * the valid contiguous stored prefix are returned, so a torn fragment never - * reaches the caller. `fromSeq` at or beyond the stored prefix returns an - * empty event list (never an error). Backends whose medium can seek by seq + * projection cache folding only the tail past its checkpoint). Unlike + * {@link inspect}, it is a detached physical suffix read: no preparation + * cache, torn-tail truncation, synthetic closers, or coordinator-state + * publication. Only events from the valid contiguous stored prefix are + * returned, so a torn fragment never reaches the caller. `fromSeq` at or + * beyond the stored prefix returns an empty event list (never an error). + * Backends whose medium can seek by seq * (SQLite) read only the suffix; sequential media (JSONL, both encodings) * still parse the whole artifact and skip forward — the primitive bounds * what is RETURNED and refolded, not every backend's physical read. @@ -1229,9 +1258,9 @@ abstract list(signal?: AbortSignal): Promise<SessionHeader[]> abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionInspection](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) · [SessionPreparation](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:70`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionProjectionCache` — `SessionProjectionCache` @@ -1562,7 +1591,7 @@ Persistence is intentionally not implemented here — persistence plugins subscr * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the - * loop's final flush is captured before the store attachment ends), do NOT use this + * loop's final events are published before the store attachment ends), do NOT use this * — fold the session lifecycle into the agent's own effect via * {@link prepare} + {@link enter} + {@link announce} (see * `dsh-agent-loop`'s creation transaction). @@ -1583,16 +1612,20 @@ create(id?: SessionId, options?: CreateSessionOptions): Session * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE * effect so a fiber unload tears the session + agent down as a single ORDERED * chain rather than as racing sibling effects — which would remove the publication hooks - * before the loop's closing `session/flush`, dropping the closing events. + * before the driver's closing events commit, dropping them. * * @param id - the session id; omitted, the store mints `session-<n>`. - * @param options - seed events and/or creation metadata for the header. + * @param options - seed events and/or creation metadata for the header. With + * `seedSource: 'persistence'`, metadata and events must be fresh detached + * graphs whose ownership transfers to this call: they are validated and + * frozen in place through {@link Session.fromRestore}, so the caller must + * retain no mutable aliases. * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, metadata is not a plain * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path. */ -prepare(id?: SessionId, options?: CreateSessionOptions): Session +prepare(id?: SessionId, options?: PrepareSessionOptions): Session /** * Enter a {@link prepare}d session into the store: install the module-private @@ -1630,10 +1663,11 @@ announce(session: Session): void /** * Dispatch the awaited `session/flush` durability checkpoint for `session`, * with the carrier captured at {@link enter}. THE flush entry point: the - * store owns the carrier, so callers (the loop's turn-end checkpoint, idle - * injection, teardown drains) must come through here rather than dispatch a - * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the - * scoped-dispatch invariant can pin it. + * store owns the carrier, so callers (the checkpoint policy's per-request + * barrier, goal-session's idle checkpoint, teardown drains, and consumers + * that flush themselves before reading storage) must come through here + * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner, + * one spelling, and the scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. * @returns whether at least one durability listener participated, after every * listener has settled successfully. @@ -1671,9 +1705,9 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) +Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:767`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1727,6 +1761,14 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. ```ts cordis-catalog +/** + * Prepare the provider's user-editable document for a native editor. File + * providers may materialize an absent document before returning its path; + * non-file providers return undefined. + * @returns the absolute local document path, or undefined for non-file storage. + */ +prepareDocument(): Promise<string | undefined> + /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes @@ -2097,7 +2139,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:91`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` @@ -2115,10 +2157,8 @@ Registry service for the prompt inputs assembled before each model step. section(section: PromptSection): () => void /** - * Register ordered cache-safe dynamic context in the calling context's scope. - * A scoped context shadows a global context with the same name; duplicates - * within one layer and non-finite orders throw. Registration and disposal - * emit `system-prompt/change`. + * Register ordered dynamic context in the calling context's scope. Scoped + * entries shadow global entries with the same name. * @param context - the context contribution to register. * @returns the exact Cordis effect disposer. */ @@ -2155,7 +2195,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly> Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:298`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) @@ -2299,7 +2339,8 @@ Replay owner for one service-wide estimator and isolated per-session folds. measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement /** - * Heuristically price one model-visible message. + * Heuristically price one model-visible message (instance face of the pure + * `estimateMessage` export from `estimate.ts`). * @param message - message to price without mutation. * @returns content and role-framing tokens under the fixed service heuristic. */ @@ -2308,7 +2349,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` @@ -2334,7 +2375,10 @@ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null /** * Prune every over-budget tool result from one stable current-surface snapshot. * Each replacement preserves the complete event data except for `content`, - * and points at the shadowed node for durable provenance and replay. + * points at the shadowed node for durable provenance and replay, and is + * immediately preceded by a `compact/prune` shadow-price event pricing the + * shadowed node through the injected token meter, so pure consumers can + * subtract it without per-node state. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. * @throws when the session rejects a replacement; replacements committed @@ -2345,7 +2389,7 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](../core-data-structures/core.md) · [PruneResult](../core-data-structures/compaction.md) · [Session](../core-data-structures/session.md) -Source: [`packages/compact/compact-tool-result-prune/src/index.ts:40`](../../packages/compact/compact-tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../packages/compact/compact-tool-result-prune/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index ef52bf6812..856ea2302b 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-primer.md cordis-primer.md: ee65e6e702ecaeb506ce7334032c38e09c936cda -cordis-primer.zh.md: ee4f6864ba7864fc95b5eb8e31acbcaea6e99825 +cordis-primer.zh.md: 051dd7c956a4db107a4ef7d1414435f9c2b2603d diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index ee4f6864ba..051dd7c956 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -10,7 +10,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 - **上下文是服务的容器。** 一个服务占据一个稳定的 `ctx.<key>`(如 `ctx.tools`、`ctx.llm`、`ctx.sessions`);其他插件通过 key 查找服务,而非导入具体实现。 - **通过 `inject` 声明服务依赖。** 插件声明所需的服务后,会等待这些服务就绪才启动;加载顺序通过服务依赖表达,而非手动编排启动序列。 - **类型化事件用于通信。** 服务通过 TypeScript 声明合并注册事件名,然后以 `emit`、`waterfall`(瀑布式事件)、`parallel` 或 `serial` 方式分发,分别对应监听者观察、包装、并行扇出或按序执行。 -- **注册是可逆的副作用。** 提示词片段、工具 schema、适配器、提供方和监听器通过 `ctx.effect()` 或 `ctx.on()` 安装,reload 和 teardown 时可预期地回卷。 +- **注册是可逆的副作用。** 提示词片段、工具 schema、适配器、提供方和监听器通过 `ctx.effect()` 或 `ctx.on()` 安装,reload 和 teardown 时会按预期撤销。 ## 分发模式 @@ -45,4 +45,4 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 将行为封装为插件:工具流水线事件属于 `ctx.tools`,模型流式输出属于 `ctx.llm`,实时 agent(智能体)协调属于 `ctx.agents`。拦截和策略优先使用事件;直接能力调用优先使用服务方法。 -每个注册都应有对应的 disposer(dispose(资源释放)函数):要么从 `ctx.effect()` 返回一个,要么使用 Cordis 提供的辅助方法自动处理。如果 teardown 顺序有要求,请将相关工作放在同一个 effect 中,以确保资源释放按预期顺序回卷。 +每个注册都应有对应的 disposer(资源释放函数):要么从 `ctx.effect()` 返回一个,要么使用 Cordis 提供的辅助方法自动处理。如果 teardown 顺序有要求,请将相关工作放在同一个 effect 中,以确保资源按预期顺序释放。 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index 6d9e4bb6fd..deed723c39 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/02-lifecycle-and-effects.md 02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 -02-lifecycle-and-effects.zh.md: a6021ed7475a0045d480810747244274eb5b4198 +02-lifecycle-and-effects.zh.md: 2e98e3af6d2f2b1b9cbb8ea38559bc1ffbf7e43b diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index a6021ed747..2e98e3af6d 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -2,11 +2,11 @@ [English](02-lifecycle-and-effects.md) | 中文 -Cordis 插件可能因配置编辑、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。 +Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。 ## Effect -对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(dispose(资源释放)函数): +对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(资源释放函数): 创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中: @@ -67,7 +67,7 @@ disposed ## Fiber 状态机 -每个已加载插件实例都拥有一个 fiber,并依次经过以下状态: +每个已加载插件实例都拥有一个 fiber,并在以下状态之间转换: ``` PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED @@ -86,8 +86,8 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 你很少需要亲自编写 `ctx.effect()`,因为内置注册 API 本身已经是 effect: - `ctx.on(event, listener)`:监听器会在卸载时移除([第 4 章](04-events.md))。 -- `ctx.plugin(child)`:子插件会随父插件一同 dispose。 -- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动回卷([第 7 章](07-into-the-harness.md))。 +- `ctx.plugin(child)`:子插件会随父插件一同 dispose(资源释放)。 +- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动撤销([第 7 章](07-into-the-harness.md))。 对于 Cordis 不管理的资源,应在 `ctx.effect()` 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。 diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index d42d5eb250..b116270811 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/03-services.md 03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 -03-services.zh.md: 3c77d0451df9062f1a344e7474e6be141b709197 +03-services.zh.md: 0f599f082573364e6ad38278e1914d8faf67faa1 diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 3c77d0451d..0f599f0825 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -2,7 +2,7 @@ [English](03-services.md) | 中文 -**服务**是一个插件提供、其他插件通过 `ctx` 消费的命名功能。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只命名 `'tools'` 之类的功能,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。 +**服务**是一个插件提供、其他插件通过 `ctx` 消费的具名能力。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只指定 `'tools'` 之类的能力,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。 ## 提供服务 @@ -73,9 +73,9 @@ Hello, world! ## 加载后仍会跟踪依赖关系 -`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会回卷。 +`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会撤销。 -这也是配置中可以替换服务的原因:卸载 `dsh-bash-local` 配置项,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会干净地重启并使用新实现。 +这也是配置中可以替换服务的原因:卸载 Cordis 配置项 `dsh-bash-local`,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会重新启动并使用新实现。 ## 可选依赖 diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index cd1fc962a7..6d21e5ff1b 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/04-events.md 04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 -04-events.zh.md: f55a61ff2f43ea42968893d07eb92ea0613b921a +04-events.zh.md: 3fdafb50303f49dca179bcaea32db211a66241f6 diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index f55a61ff2f..3fdafb5030 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { 每个 harness 事件都会在生成的[事件目录](../cordis-catalog/events.md)中记录其模式。 -## Waterfall:转换或短路 +## waterfall:转换或短路 waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`: diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index deb6f119c2..01047ad516 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md 05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 -05-config.zh.md: 52a75e40672c9a08d285677dd14dcd404b925e5a +05-config.zh.md: 0c8170f518f0c87ab5c754606436496a4ff9d51e diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index 52a75e4067..0c8170f518 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -2,7 +2,7 @@ [English](05-config.md) | 中文 -每个 `cordis.yml` 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。 +`cordis.yml` 中的每个 Cordis 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。 ## 可配置插件 @@ -65,7 +65,7 @@ ValidationError: invalid config: - $.targets expected array but got not-an-array (at targets) ``` -插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的 schema 有效配置命名了不可用的资源或提供方,该插件也应当在能解析该引用时立即拒绝。 +插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。 ## 计算得到的配置值 @@ -77,8 +77,8 @@ ValidationError: invalid config: apiKey: !!js process.env.DEEPSEEK_API_KEY ``` -`!!js` **仅在 `config` 内有效**。配置项元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 +`!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 -下一章:[组合与 HMR](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 +下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index 01f9345de3..f7abfca742 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/06-composition-and-hmr.md 06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 -06-composition-and-hmr.zh.md: ebe63fc26607ae6d9344c4795a7975496ed901b5 +06-composition-and-hmr.zh.md: 7c0a94b0abcc0f153f59391fd009f1e0b40500e5 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index ebe63fc266..7c0a94b0ab 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -2,11 +2,11 @@ [English](06-composition-and-hmr.md) | 中文 -到目前为止构建的每项功能都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。 +到目前为止构建的每项能力都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。 -## 配置项不只有名称 +## Cordis 配置项不只有名称 -配置项除了 `name` 和 `config`,还接受其他元数据: +Cordis 配置项除了 `name` 和 `config`,还接受其他元数据: ```yaml - id: greeter # stable identity for this entry @@ -16,9 +16,9 @@ disabled: true # keep the entry, skip mounting it ``` -`id` 为配置项提供稳定标识,使 loader 能区分修改现有配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。 +`id` 为 Cordis 配置项提供稳定标识,使 loader 能区分修改现有 Cordis 配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其 Cordis 配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。 -组可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。 +组可以嵌套一份 Cordis 配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。 ## 热模块替换 @@ -39,7 +39,7 @@ name: './hello.ts' ``` -列表中增加了两个支持插件:HMR 通过 Cordis logger 服务记录日志,因此没有 console exporter 时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 +列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis: @@ -56,7 +56,7 @@ hello from my first plugin hello from my EDITED plugin ``` -旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 `id` 的原因:不带该字段的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。 +旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较 Cordis 配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述 Cordis 配置项显式携带 `id` 的原因:不带该字段的 Cordis 配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。 ## 诊断始终无法加载的插件 diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index c85bcad755..5faa0bf213 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md 07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc -07-into-the-harness.zh.md: 32b21b008837e2972a53db9d893788dc6a7de9a9 +07-into-the-harness.zh.md: 903adb903aa4c4355b92eb34e89f218a0295767c diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 32b21b0088..903adb903a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -46,7 +46,7 @@ export function apply(ctx: Context) { } ``` -这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则另行生成原生且持久的结果内容。 +这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则作为 Native renderer(原生渲染器),另行生成可持久化的结果内容。 ## 观察插件 @@ -91,7 +91,7 @@ node --import tsx ../../vendor/cordis/bin.js tool replied: [{"type":"text","text":"Hello, Cordis!"}] ``` -logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `execute` 的 promise 向调用方返回结果。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。 +logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 `execute` 向调用方返回的 promise 兑现之前。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。 ## 从这里走向完整 agent(智能体) @@ -100,7 +100,7 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `ex 后续可以阅读: - [构建工具](../user/develop/basic/tool.md):深入了解 `defineTool`,包括呈现和更丰富的 schema。 -- [三层功能设计](../user/develop/practice/index.md):harness 如何组织可替换功能。 +- [三层能力设计](../user/develop/practice/index.md):harness 如何组织可替换能力。 - 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 - [架构](../architecture.md):这些插件所处的系统地图。 diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 275c700851..256bc4f629 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md index.md: af622ad4e35829c6283c40f1b0019d7959dac973 -index.zh.md: 35bad552ecce9c0496b0ed88b041a8109c81945b +index.zh.md: 0b7684a9532a1efdcc3ea2d067da23852d146e2f diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 35bad552ec..0b7684a953 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -6,7 +6,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 [TypeScript 说明](#typescript-notes)会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。 -如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md)页面。 +如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md) 页面。 ## 准备工作 @@ -39,7 +39,7 @@ node --import tsx ../../vendor/cordis/bin.js 2. [生命周期与 effect](02-lifecycle-and-effects.md):由 Cordis 管理的注册会在所属插件卸载时撤销。 3. [服务](03-services.md):在 `ctx` 上公开一项能力,并通过 `inject` 依赖它。 4. [事件](04-events.md):类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。 -5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时快速失败。 +5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时明确报错。 6. [组合与 HMR(热模块替换)](06-composition-and-hmr.md):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。 7. [进入 harness](07-into-the-harness.md):基于真实的 harness 服务注册一个可由模型调用的工具。 diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml index 4d9cc05ac0..0679cd47bd 100644 --- a/docs/core-data-structures/approval.i18n.yaml +++ b/docs/core-data-structures/approval.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/approval.md approval.md: f1889b25e2bbbcb157b0bced070b1f157a867504 -approval.zh.md: c460ec2cb847a9e9a3e772987830b58e93fc3715 +approval.zh.md: 48222991312f9ae97c9249f1232b261d2393d282 diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md index c460ec2cb8..4822299131 100644 --- a/docs/core-data-structures/approval.zh.md +++ b/docs/core-data-structures/approval.zh.md @@ -2,13 +2,13 @@ [English](approval.md) | 中文 -[dsh-user-approval](../../packages/ui/user-approval) 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、`ctx.approval` 分发服务、`approval/request` 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 `ask`/`never` 策略。UI 通道可以提供人类应答者;[ACP(Agent Client Protocol)自动化桥接层](../../packages/acp/acp)为其拥有的 agent 提供一次性机器决策。调用方如 [dsh-tools](../../packages/core/tools) 和 [dsh-tool-bash](../../packages/bash/tool-bash) 消费闭合的结果,除非结果为 `allowed-once`,否则一律拒绝。 +[dsh-user-approval](../../packages/ui/user-approval) 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、`ctx.approval` 分发服务、`approval/request` 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 `ask`/`never` 策略。UI 通道可以提供人类应答者;[ACP(Agent Client Protocol)自动化桥接层](../../packages/acp/acp)为其拥有的 agent(智能体)提供一次性机器决策。调用方如 [dsh-tools](../../packages/core/tools) 和 [dsh-tool-bash](../../packages/bash/tool-bash) 消费闭合的结果,除非结果为 `allowed-once`,否则一律拒绝。 源码:[`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts) ## 标识与结果 -每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent(智能体)/会话 id 互换。 +每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/会话 id 互换。 ```ts type-equiv /** @@ -18,7 +18,7 @@ type ApprovalRequestId = Branded<'ApprovalRequestId'> ``` -`ApprovalOutcome` 是闭合的,且默认拒绝。`allowed-once` 仅授权所询问的那一个操作;调用方对 `rejected`、`cancelled` 和 `unavailable` 均执行拒绝。缺失、无所有权、抛异常或不合规的应答者会产生 `unavailable`,而非放行。 +`ApprovalOutcome` 是闭合的,且失败时拒绝。`allowed-once` 仅授权所询问的那一个操作;调用方对 `rejected`、`cancelled` 和 `unavailable` 均执行拒绝。缺失、不负责该请求、抛异常或不合规的应答者会产生 `unavailable`,而非放行。 ```ts type-equiv /** @@ -84,6 +84,6 @@ interface ApprovalRequest { ## 分发与审计 -`ctx.approval.request(req)` 要求发起请求的会话处于一个打开的轮次内。它追加 `approval/asked`,获取一个结果,追加对应的 `approval/decided`,然后以该结果 resolve。`never` 策略在服务内部、waterfall 分发之前强制执行,因此即使后来以 `prepend` 注册的应答者也无法绕过它。应答者在拥有该请求时返回结果,否则调用 `next()` 委托;第一个应答占据唯一的决策槽位。 +`ctx.approval.request(req)` 要求发起请求的会话处于一个尚未结束的轮次内。它追加 `approval/asked`,获取一个结果,追加对应的 `approval/decided`,然后以该结果完成。`never` 策略在服务内部、waterfall 分发之前强制执行,因此即使后来以 `prepend` 注册的应答者也无法绕过它。应答者在拥有该请求时返回结果,否则调用 `next()` 委托;第一个应答占据唯一的决策槽位。 审计事件仅写入日志,不进入模型 transcript(文本记录)。模型可见的行为是调用方派生的工具结果与当前运行时上下文快照。服务 dispose(资源释放)时会移除其上下文贡献;应答者监听器独立地通过 effect 绑定到其所属插件。 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 9d261fe939..30e15dbacc 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -bash.md: 3747244662301a256e12037ea67c21017b5ac2c5 -bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15 +# pnpm run verify-translation-pairing --write docs/core-data-structures/bash.md +bash.md: f83a133c8e049bc111cfabd2b59def18a1b2d607 +bash.zh.md: 27ca9f6e5a8e05cd4ffa86e72d433b6f22005f09 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 3747244662..f83a133c8e 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -218,4 +218,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). `dsh-bash` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill. diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 9927aa8d51..27ca9f6e5a 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,7 +2,7 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 task id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) @@ -12,7 +12,7 @@ bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash ## 请求与规格:`resolve()` 拆分 -该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs`/`stdoutMaxBytes` 可选,由配置或请求策略补全)与执行器实际使用的**完全解析后的 spec**(这些字段均为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`——这具体落实了仓库的「包(package) seam 上显式优于隐式」规则:`BashExecSpec` 的读者不必猜测工作目录或输出预算来自何处。 +该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs`/`stdoutMaxBytes` 可选,由配置或请求策略补全)与执行器实际使用的**完全解析后的 spec**(这些字段均为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`——这具体落实了仓库的「包 seam 上显式优于隐式」规则:`BashExecSpec` 的读者不必猜测工作目录或输出预算来自何处。 ```ts type-equiv /** @@ -98,13 +98,13 @@ interface BashExecSpec { } ``` -`stdin` 和 `env` 是受信任的进程内插件输入,不由 `dsh-tool-bash` 暴露。本地执行器会先清除环境中的凭据,再合并调用方显式提供的 env。见 [bash-stdin-env Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 +`stdin` 和 `env` 是受信任的进程内插件输入,不由 `dsh-tool-bash` 暴露。本地执行器会先清除环境中的凭据,再合并调用方显式提供的 env。见 [bash-stdin-env Agent Note](../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 `stdoutMaxBytes` 同样仅供受信任插件使用。它让前台消费方能在有界解析预算内请求完整 stdout,而不会改变 stderr、后台任务或面向模型的 bash 工具的常规输出上限。 ## 前台运行:`BashRunResult` -一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以同时超时并以退出码 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被截断的运行误读为干净的成功。 +一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以同时超时并以退出码 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被提前中断的运行误读为正常成功。 ```ts type-equiv /** The outcome of one completed (or killed) foreground run. */ @@ -166,7 +166,7 @@ interface BashSandboxInfo { ## 后台进程:`BashProcess` -`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.tasks.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时 resolve 且绝不 reject;进程结束后仍可读取,并且沙箱事实会在 `done` resolve 前写入。 +`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.tasks.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时完成且绝不被拒绝;进程结束后仍可读取,并且沙箱事实会在 `done` 完成前写入。 ```ts type-equiv /** @@ -200,7 +200,7 @@ interface BashProcess { } ``` -`readOutput()` 返回增量 delta 与 spill 恢复事实: +`readOutput()` 返回增量内容与 spill 恢复信息: ```ts type-equiv /** One incremental {@link BashProcess.readOutput} read. */ @@ -218,4 +218,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。`dsh-bash` 拥有 shell 工具共享的退出状态契约:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index 218ef4eea9..fbdee4c938 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52 -code-runtime.zh.md: 4b14aeb2183010e8140540258ce8109df9f59910 +code-runtime.zh.md: daf07aaf613852a6c4a7b1aff152fcc61052fbca diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 4b14aeb218..daf07aaf61 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -2,13 +2,13 @@ [English](code-runtime.md) | 中文 -代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端与工具注册表消费方的契约见 [Code Mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)和[类型化返回契约](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)使用宿主提供的异步绑定运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端与工具注册表消费方的契约见 [Code Mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 和[类型化返回契约](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) ## 运行:请求进,结果出 -`CodeRunRequest` 携带**运行时所需的一切**。按照「包(package)边界处显式优于隐式」的规则,默认值(时间预算、输出上限)来自实现的已校验配置,绝不是 `run()` 内部隐藏的 `??`: +`CodeRunRequest` 携带**运行时要处理的一切内容**。按照「包边界处显式优于隐式」的规则,默认值(时间预算、输出上限)来自实现的已校验配置,绝不是 `run()` 内部隐藏的 `??`: ```ts type-equiv /** @@ -36,7 +36,7 @@ interface CodeRunRequest { } ``` -结果将错误报告为一个**字段**,而非 `run()` 的 rejection。报告失败的程序是调用方的职责,不走异常路径(与 `BashExecutor.run` 的 resolve-on-failure 契约一致): +结果将错误报告为一个**字段**,而不是让 `run()` 返回被拒绝的 Promise。报告程序失败是调用方的职责,不走异常路径(与 `BashExecutor.run` 失败时仍正常完成的契约一致): ```ts type-equiv /** @@ -144,4 +144,4 @@ interface CodeRunFailure { ## 服务 -`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 完成前,进行中的运行都已终止并等待结束。 +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 要等到所有进行中的运行均已终止并结算后才完成。 diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml index ba55abec39..2227cd0b59 100644 --- a/docs/core-data-structures/commands.i18n.yaml +++ b/docs/core-data-structures/commands.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/commands.md commands.md: 056c775f4c2e1586447db11821e5c7d56be01881 -commands.zh.md: 1a51305df356d8becf8c5517704dc375cdb8b585 +commands.zh.md: 6339b3e87c04eac0fd140a7cab57b2ad18bbf5c2 diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md index 1a51305df3..6339b3e87c 100644 --- a/docs/core-data-structures/commands.zh.md +++ b/docs/core-data-structures/commands.zh.md @@ -2,7 +2,7 @@ [English](commands.md) | 中文 -[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包(package)README](../../packages/ui/commands/README.md) 负责组合方式与限制。 +[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包 README](../../packages/ui/commands/README.md) 负责组合方式与限制。 来源:[`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) @@ -38,7 +38,7 @@ interface CommandDefinition { ## 调用与结果 -适配器拥有取消操作,并传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。 +取消由适配器负责,适配器会传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。 ```ts type-equiv /** Invocation passed to one registered command handler. */ diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 972521f7c0..a0c3f27f88 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 070adc65fb25b2536c88701c375cc0b2a5308559 -compaction.zh.md: 9167882f63b2931ba3ce49697e0c87164394af89 +compaction.md: fe64ffe2707ab4a41f1db186965b944d525684c3 +compaction.zh.md: ea694f30dcc50ef48847ffbd5efdeed36aa234c5 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 070adc65fb..fe64ffe270 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -62,18 +62,24 @@ Automatic callers state why policy is running; implementations may treat confirm type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` synchronously reserves the agent's next-turn admission, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, flushes a closed attempt, and then releases admission so ordinary queued prompts derive from the new surface. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. The predicate keeps checkpoint recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. The predicate keeps checkpoint recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. Expected manual failures use `ManualCompactionErrorCode`: ```ts type-equiv /** Expected failure classes for an explicit idle-session compaction request. */ -type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +type ManualCompactionErrorCode = + | 'busy' + | 'cancelled' + | 'changed' + | 'summary' + | 'commit' + | 'persistence' ``` `changed` and `summary` leave the conversation surface unchanged but still close and persist the failed attempt in the log. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup. -Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/pre-step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 9167882f63..ea694f30dc 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -2,7 +2,7 @@ [English](compaction.md) | 中文 -压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和面向用户的消费方([dsh-command-compact](../../packages/compact/command-compact))。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包(package)。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 +压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和面向用户的消费方([dsh-command-compact](../../packages/compact/command-compact))。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -62,18 +62,24 @@ interface CompactionResult { type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 会同步预留 agent 的下一轮次接纳;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对;flush 已闭合尝试;随后释放接纳预留,使普通排队提示词从新表层派生。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该值和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出两者。该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 +`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该值和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出两者。该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 预期的手动失败使用 `ManualCompactionErrorCode`: ```ts type-equiv /** Expected failure classes for an explicit idle-session compaction request. */ -type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +type ManualCompactionErrorCode = + | 'busy' + | 'cancelled' + | 'changed' + | 'summary' + | 'commit' + | 'persistence' ``` `changed` 和 `summary` 保持会话表层不变,但仍会闭合失败尝试并将其持久化到日志。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。 -压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/pre-step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 415d74e6f6..6f16b8c573 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 1b5704384157688b45ae0900bf2d9924426bbd6b -core.zh.md: 05802039920163ac8185703ab483c5603087ed96 +core.md: 495651e1f3105afff15f68822568ff71c531da4f +core.zh.md: c895601f39d350811ab1169533287d8f59dba703 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b57043841..495651e1f3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -69,14 +69,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -Six canonical maps use this pattern; a plugin author extends these: +Five canonical maps use this pattern; a plugin author extends these: | Map | Package | Derives | Catalog | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [below](#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [below](#content-blocks-and-messages) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [below](#the-model-request-and-result) | -| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | @@ -161,12 +160,84 @@ Where a message came from is itself a merge-extensible sum type: */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } ``` +Provenance and shape are two independent axes. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content: + +```ts type-equiv +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + /** Current state, where a later snapshot from the same producer supersedes an earlier one. */ + | 'snapshot' + /** A one-off account of something that just happened; it supersedes nothing. */ + | 'notice' + /** A message another agent addressed to this one. */ + | 'relay' + /** Material lifted out of another session's log, possibly reduced on the way in. */ + | 'recall' +``` + +```ts type-equiv +/** One named contribution to a `snapshot`-form context, in assembly order. */ +interface ContextSnapshotSection { + /** The contributing subsystem's name. */ + readonly name: string + /** That contribution's model-facing text, exactly as assembled. */ + readonly text: string +} +``` + +```ts type-equiv +/** + * Producer-declared {@link ContextForm} and the fields that form requires, + * mixed into the source shapes that carry one. + * + * Discriminated by `form` so a producer cannot declare a shape without the + * facts that shape is presented from: a `notice` must record its one-line + * account, a `snapshot` its sections. Omitting `form` stays valid — an + * undeclared context is the documented default. + */ +type ContextFormed = + | { readonly form?: never } + | { readonly form: 'instructions' } + | { readonly form: 'catalog' } + | { + readonly form: 'snapshot' + /** The named contributions this snapshot assembled, in order. */ + readonly sections: readonly ContextSnapshotSection[] + } + | { + readonly form: 'notice' + /** One-line account of what happened, shown without expanding the row. */ + readonly summary: string + } + | { readonly form: 'relay' } + | { readonly form: 'recall' } +``` + ## Streaming Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`. @@ -445,7 +516,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -473,7 +544,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The session event variants, `deriveMessages()` projection rules, `TurnEndReason` vocabulary, and execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -482,72 +553,11 @@ The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ` Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -type SendTarget = 'next-turn' | 'next-step' +/** One of the two ordered pending-message lists owned by an agent. */ +type InboxTarget = 'next-turn' | 'next-step' ``` -```ts type-equiv -/** Resolved inbox placement reported when an accepted message is enqueued. */ -type InboxPlacement = 'queued' | 'steering' -``` - -`InboxItemId` is a process-local branded string minted for each accepted FIFO occurrence. It is intentionally distinct from `MessageId`: sending the same immutable message twice creates two independently addressable pending items. - -```ts type-equiv -/** One independently addressable accepted occurrence in an agent inbox. */ -interface InboxItem { - /** Agent-loop-minted occurrence identity. */ - readonly id: InboxItemId - /** Identified message delivered by the caller. */ - readonly message: UserMessage - /** Acceptance-time FIFO classification. */ - readonly placement: InboxPlacement -} -``` - -```ts type-equiv -/** A user-requested mutation of one still-pending queued occurrence. */ -type InboxAction = - | { readonly kind: 'edit'; readonly content: ContentBlock[] } - | { readonly kind: 'remove' } - | { readonly kind: 'steer' } -``` - -```ts type-equiv -/** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces content or strict steer transfers the immutable message. The original queued occurrence ends and strict steer accepts a new steering occurrence with a distinct `InboxItemId`. Injection bypasses the FIFOs and never appears on inbox lifecycle events. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -555,28 +565,25 @@ interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no canceled inbox splice is logged. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } ``` -`SteeringReceipt.outcome` always resolves. `admitted` identifies the turn and step whose immutable request history contains that exact message; `rejected` means lifecycle or terminal policy discarded it first. Synchronous input validation still throws from `steer()`. - ```ts type-equiv -/** Stable runtime cause accepted by {@link Agent.cancel}. */ +/** Why an active agent driver was cancelled. */ type AgentCancelCause = | { readonly kind: 'user' } | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } ``` -`Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. +`Agent` is an interface over the public live-agent contract. Its unified `send` method exposes target and wakeup routing directly; `followup`, `steer`, and `inject` are fixed-preset aliases. ```ts type-equiv -/** - * Public live-agent handle with aliases over the unified delivery primitive. - * @typert object - */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -584,118 +591,81 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - - /** - * Reserve admission of the next ordinary turn while this agent is idle, so an - * operation can mutate durable history before any queued prompt derives a - * request from it. Already-accepted waking work has right of way, including a - * send whose wake is still a pending microtask. Later sends keep their - * ordinary placement, FIFO order, and `wakeup` facts, and - * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes - * a queued follow-up rather than steering; cancellation and disposal may - * still discard them. {@link inject} is not withheld. {@link whenIdle} treats - * a live reservation as activity, while lifecycle teardown does not await it. - * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. - */ - reserveTurnAdmission(): (() => void) | undefined - - /** - * Mutate one still-pending queued occurrence synchronously. Editing preserves - * the message identity and queue position; removal publishes its terminal - * discard. Steer strictly transfers the message into the current next-step - * window, or returns `steer-unavailable` without changing the queued - * occurrence. Steering occurrences and driver-claimed items return - * `not-found`. - * @param id - independently addressable queued occurrence. - * @param action - edit, remove, or strict steer operation. - * @returns the applied outcome or the reason no mutation occurred. - */ - updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. - * @param cause - the stable caller intent carried by the current turn signal. + * turn or between-turn task. The first cause wins for that activity. With no + * active activity, cancellation is a no-op and does not arm later work. + * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work started before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no active driver or maintenance task remains. + */ whenIdle(): Promise<void> /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Run one non-turn maintenance task from the true idle phase. The task starts + * synchronously after claiming that phase; later waking input remains in the + * inbox until the task settles, while public status stays `idle`. + * `whenIdle()` follows both the task and any waking work released behind it. + * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. + * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @returns the task promise. + */ + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> + + /** + * Route identified input to an inbox boundary and optionally wake the driver. + * Waking input submitted after active cancellation is queued for the next turn. + * @param message - identified content and its producer provenance. + * @param target - the preferred next-turn or next-step inbox boundary. + * @param wakeup - whether delivery may wake the driver. + */ + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + + /** + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering with a message-owned admission receipt — the - * `next-step`/wakeup preset of {@link send}. During prompt admission or an - * open turn, the message waits in the steering FIFO until a committed step - * snapshots it; outside that window it enters the ordinary queued FIFO. The - * receipt resolves `admitted` only after the message joins that step's - * immutable request history, or `rejected` when terminal policy, - * cancellation, or disposal discards it first. A non-terminal turn close may - * leave it staged for a later admitted prompt without settling the receipt. + * Submit steering for the nearest step. An idle driver starts a turn; + * a running driver consumes it at its next step boundary. + * A rejected step leaves steering parked in the inbox until the next + * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. - * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): SteeringReceipt + steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. A running driver claims it at the nearest later step boundary; + * idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void } ``` -`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. A live turn-admission reservation is quiescence-relevant without changing `status` or turning later queue entries into steering; its only authority is to defer the next driver claim until release. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `followup()` returns no handle: its `MessageId` identifies durable inbox insertion, claim, and discard facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([decision](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. @@ -705,22 +675,31 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Prompt and post-tool decisions use the same identified `UserMessage` shape as durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its identity and provenance. Hook bridges map their native decision fields onto these typed results. +Pre-step decisions use the same identified `UserMessage` shape as durable user-role input. The entered batch is authoritative and preserves every message's identity and provenance. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events: +`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: ```ts type-equiv -/** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. - */ -type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } +/** Coordinates and cancellation for a proposed step. */ +interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} +``` + +It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: + +```ts type-equiv +/** Whether and with which messages the loop enters a proposed step. */ +type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } ``` `agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. @@ -730,12 +709,7 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -```ts type-equiv -/** Model-request failure with an optional machine-routable provider code. */ -type RequestError = Error & { code?: string } -``` - -`agent/step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. +`agent/pre-step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0580203992..c895601f39 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -71,14 +71,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -六个规范 map 使用此模式;插件作者扩展它们: +五个规范 map 使用此模式;插件作者扩展它们: | Map | 包 | 派生 | 目录 | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [下文](#the-model-request-and-result) | -| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | @@ -167,12 +166,84 @@ interface Message { */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } ``` +溯源与形态是相互独立的两根轴。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现: + +```ts type-equiv +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + /** Current state, where a later snapshot from the same producer supersedes an earlier one. */ + | 'snapshot' + /** A one-off account of something that just happened; it supersedes nothing. */ + | 'notice' + /** A message another agent addressed to this one. */ + | 'relay' + /** Material lifted out of another session's log, possibly reduced on the way in. */ + | 'recall' +``` + +```ts type-equiv +/** One named contribution to a `snapshot`-form context, in assembly order. */ +interface ContextSnapshotSection { + /** The contributing subsystem's name. */ + readonly name: string + /** That contribution's model-facing text, exactly as assembled. */ + readonly text: string +} +``` + +```ts type-equiv +/** + * Producer-declared {@link ContextForm} and the fields that form requires, + * mixed into the source shapes that carry one. + * + * Discriminated by `form` so a producer cannot declare a shape without the + * facts that shape is presented from: a `notice` must record its one-line + * account, a `snapshot` its sections. Omitting `form` stays valid — an + * undeclared context is the documented default. + */ +type ContextFormed = + | { readonly form?: never } + | { readonly form: 'instructions' } + | { readonly form: 'catalog' } + | { + readonly form: 'snapshot' + /** The named contributions this snapshot assembled, in order. */ + readonly sections: readonly ContextSnapshotSection[] + } + | { + readonly form: 'notice' + /** One-line account of what happened, shown without expanding the row. */ + readonly summary: string + } + | { readonly form: 'relay' } + | { readonly form: 'recall' } +``` + ## 流式输出 适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 @@ -451,7 +522,7 @@ interface LlmCallConfigAdapterDefaults { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -479,7 +550,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +会话事件变体、`deriveMessages()` 投影规则、`TurnEndReason` 词汇以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 <a id="the-agent-handle"></a> @@ -490,72 +561,11 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -type SendTarget = 'next-turn' | 'next-step' +/** One of the two ordered pending-message lists owned by an agent. */ +type InboxTarget = 'next-turn' | 'next-step' ``` -```ts type-equiv -/** Resolved inbox placement reported when an accepted message is enqueued. */ -type InboxPlacement = 'queued' | 'steering' -``` - -`InboxItemId` 是为每次获准进入 FIFO 的项铸造的进程本地品牌字符串。它有意区别于 `MessageId`:同一条不可变消息发送两次,会创建两个可独立寻址的待处理项。 - -```ts type-equiv -/** One independently addressable accepted occurrence in an agent inbox. */ -interface InboxItem { - /** Agent-loop-minted occurrence identity. */ - readonly id: InboxItemId - /** Identified message delivered by the caller. */ - readonly message: UserMessage - /** Acceptance-time FIFO classification. */ - readonly placement: InboxPlacement -} -``` - -```ts type-equiv -/** A user-requested mutation of one still-pending queued occurrence. */ -type InboxAction = - | { readonly kind: 'edit'; readonly content: ContentBlock[] } - | { readonly kind: 'remove' } - | { readonly kind: 'steer' } -``` - -```ts type-equiv -/** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换内容或严格 steering(中途引导)转移不可变消息时,其 `MessageId` 都保持稳定。原 queued 单次入队项会结束,严格 steering 则接受一个具有不同 `InboxItemId` 的新 steering 单次入队项。注入绕过两个 FIFO,从不出现在 inbox 生命周期事件中。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过无 outcome 的纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -563,28 +573,25 @@ interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no canceled inbox splice is logged. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } ``` -`SteeringReceipt.outcome` 始终会解析。`admitted` 标识其不可变请求历史包含该确切消息的轮次与步骤;`rejected` 表示生命周期或终止策略先丢弃了该消息。同步输入校验仍会从 `steer()` 抛出异常。 - ```ts type-equiv -/** Stable runtime cause accepted by {@link Agent.cancel}. */ +/** Why an active agent driver was cancelled. */ type AgentCancelCause = | { readonly kind: 'user' } | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } ``` -`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 +`Agent` 是覆盖公开活跃 agent 契约的接口。它的统一 `send` 方法直接公开目标与唤醒路由;`followup`、`steer` 和 `inject` 是固定预设别名。 ```ts type-equiv -/** - * Public live-agent handle with aliases over the unified delivery primitive. - * @typert object - */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -592,118 +599,81 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - - /** - * Reserve admission of the next ordinary turn while this agent is idle, so an - * operation can mutate durable history before any queued prompt derives a - * request from it. Already-accepted waking work has right of way, including a - * send whose wake is still a pending microtask. Later sends keep their - * ordinary placement, FIFO order, and `wakeup` facts, and - * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes - * a queued follow-up rather than steering; cancellation and disposal may - * still discard them. {@link inject} is not withheld. {@link whenIdle} treats - * a live reservation as activity, while lifecycle teardown does not await it. - * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. - */ - reserveTurnAdmission(): (() => void) | undefined - - /** - * Mutate one still-pending queued occurrence synchronously. Editing preserves - * the message identity and queue position; removal publishes its terminal - * discard. Steer strictly transfers the message into the current next-step - * window, or returns `steer-unavailable` without changing the queued - * occurrence. Steering occurrences and driver-claimed items return - * `not-found`. - * @param id - independently addressable queued occurrence. - * @param action - edit, remove, or strict steer operation. - * @returns the applied outcome or the reason no mutation occurred. - */ - updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. - * @param cause - the stable caller intent carried by the current turn signal. + * turn or between-turn task. The first cause wins for that activity. With no + * active activity, cancellation is a no-op and does not arm later work. + * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work started before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no active driver or maintenance task remains. + */ whenIdle(): Promise<void> /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Run one non-turn maintenance task from the true idle phase. The task starts + * synchronously after claiming that phase; later waking input remains in the + * inbox until the task settles, while public status stays `idle`. + * `whenIdle()` follows both the task and any waking work released behind it. + * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. + * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @returns the task promise. + */ + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> + + /** + * Route identified input to an inbox boundary and optionally wake the driver. + * Waking input submitted after active cancellation is queued for the next turn. + * @param message - identified content and its producer provenance. + * @param target - the preferred next-turn or next-step inbox boundary. + * @param wakeup - whether delivery may wake the driver. + */ + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + + /** + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering with a message-owned admission receipt — the - * `next-step`/wakeup preset of {@link send}. During prompt admission or an - * open turn, the message waits in the steering FIFO until a committed step - * snapshots it; outside that window it enters the ordinary queued FIFO. The - * receipt resolves `admitted` only after the message joins that step's - * immutable request history, or `rejected` when terminal policy, - * cancellation, or disposal discards it first. A non-terminal turn close may - * leave it staged for a later admitted prompt without settling the receipt. + * Submit steering for the nearest step. An idle driver starts a turn; + * a running driver consumes it at its next step boundary. + * A rejected step leaves steering parked in the inbox until the next + * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. - * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): SteeringReceipt + steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. A running driver claims it at the nearest later step boundary; + * idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void } ``` -`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。活动的轮次接纳预留与完全停稳相关,但不会改变 `status`,也不会把之后的队列项变成 steering;它的唯一权限是将驱动器的下一次认领延迟到释放时。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`followup()` 不返回 handle:其 `MessageId` 标识持久 inbox 的插入、领取与丢弃事实,而不标识之后的助手输出或轮次结束。`whenIdle()` 观察整个 agent,因此只有显式拥有从回执到 idle 这一完整区间的调用方才能将其称为一次运行([决策](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 @@ -713,22 +683,31 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella ## 拦截决策 -提示词决策与工具后决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。每个 `additionalContexts` 条目都会成为一条独立的 `user/message`,保留各自的标识与 provenance。钩子桥接层把其原生决策字段映射到这些类型化结果上。 +pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。进入步骤的批次具有权威性,并保留每条消息的标识与 provenance。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 可以改写已领取的提示词或附加 `additionalContexts`;block 拒绝准入且不产生任何轮次事件: +`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: ```ts type-equiv -/** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. - */ -type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } +/** Coordinates and cancellation for a proposed step. */ +interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} +``` + +它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: + +```ts type-equiv +/** Whether and with which messages the loop enters a proposed step. */ +type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } ``` `agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 @@ -738,12 +717,7 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -```ts type-equiv -/** Model-request failure with an optional machine-routable provider code. */ -type RequestError = Error & { code?: string } -``` - -`agent/step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 +`agent/pre-step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index e360dd99d3..91ac0ed81d 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/filesystem.md filesystem.md: 110c1fd428b15c5094f9dcc94050cad61c324373 -filesystem.zh.md: aca450364c05c6f756c36fccc11be7246767f3a4 +filesystem.zh.md: 010ec22a5d4a29555425c3deede08b317cba7004 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index aca450364c..010ec22a5d 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -69,7 +69,7 @@ interface FsInfo { } ``` -`lstat` 是路径层级、不跟随链接的元数据原语。它接收路径而不是 `FsTarget`,因为 `resolve` 会有意跟随 symlink 以产生稳定标识;需要检查信任边界的消费方可以先调用 `lstat`,在解析前拒绝 `symlink`。 +`lstat` 是路径级、不跟随链接的元数据原语。它接收路径而不是 `FsTarget`,因为 `resolve` 会有意跟随 symlink 以产生稳定标识;需要检查信任边界的消费方可以先调用 `lstat`,在解析前拒绝 `symlink`。 ```ts type-equiv /** @@ -111,7 +111,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方 seam) -`writeText` 和 `editText` 的版本守卫都是可选的:省略它执行无条件(裸提供方)变更,提供它则启用守卫。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 ```ts type-equiv /** @@ -179,11 +179,11 @@ interface FsEditOutcome { `dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。 -`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不守卫该 emit——抛异常的监听方会在一次已成功的变更上表现为工具的 `isError` 结果。生成的目录在 [events.md](../cordis-catalog/events.md) 中展示确切签名。 +`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方会导致工具为一次已经成功的变更返回 `isError` 结果。生成的目录在 [events.md](../cordis-catalog/events.md) 中展示确切签名。 ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包(package)。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 ```ts type-equiv /** diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml index 0cdd666254..625556f99a 100644 --- a/docs/core-data-structures/goal.i18n.yaml +++ b/docs/core-data-structures/goal.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -goal.md: 704a93320cc38d1b9400edc2d9ad2342bc11dccd -goal.zh.md: b2e083843a70823bf6a6b43e046b1f38f4e11e22 +# pnpm run verify-translation-pairing --write docs/core-data-structures/goal.md +goal.md: fc6a7e63e58fc7cd4bc524be1e66515593680d95 +goal.zh.md: c584cb375bd55c13c158e7ee22d7721040dffa05 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index 704a93320c..fc6a7e63e5 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -71,10 +71,10 @@ interface GoalView extends GoalSnapshot { ## Durable changes -Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. +Every mutation is a durable `goal/change` session event whose payload is either a complete post-mutation snapshot or a clear tombstone. The strict fold and persisted projection derive lifecycle state only from these events; inbox mutations do not affect goal state. ```ts type-equiv -/** Full-snapshot goal mutation retained in a model-visible context event. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -97,18 +97,16 @@ interface GoalClearChangeMeta { } ``` -Goal state changes use round `0`. A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; replay rejects gaps, stale revisions, stopped phases, and cap overflow. +A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; only these admitted `user/message` events advance `roundsStarted`. Replay rejects non-positive rounds, gaps, stale revisions, stopped phases, and cap overflow. ```ts type-equiv -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } ``` @@ -133,7 +131,7 @@ interface EditGoalRequest { ``` ```ts type-equiv -/** Live notification after one goal mutation has been accepted for logging. */ +/** Live notification after one durable goal mutation commits. */ interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -144,4 +142,4 @@ interface GoalChanged { ## Service behavior -[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay, enforces exact-live-agent identity and compare-and-set mutations, overlays deferred injections, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md index b2e083843a..c584cb375b 100644 --- a/docs/core-data-structures/goal.zh.md +++ b/docs/core-data-structures/goal.zh.md @@ -2,7 +2,7 @@ [English](goal.md) | 中文 -事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 +事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 ## 标识与生命周期 @@ -71,10 +71,10 @@ interface GoalView extends GoalSnapshot { ## 持久变更 -每次变更都是 Round 编号为 0、来源为目标的 `user/message`,其元数据要么是完整快照,要么是清除墓碑。版本、元数据、目标来源和逐字渲染内容共同构成一项回放不变量。 +每次变更都是持久的 `goal/change` 会话事件,其载荷要么是变更后的完整快照,要么是清除墓碑。严格折叠与持久投影只从这些事件派生生命周期状态;inbox 变更不会影响 goal 状态。 ```ts type-equiv -/** Full-snapshot goal mutation retained in a model-visible context event. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -97,24 +97,22 @@ interface GoalClearChangeMeta { } ``` -目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。 +续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;只有这些获准的 `user/message` 事件会推进 `roundsStarted`。回放会拒绝非正数 Round、编号缺口、陈旧修订号、已停止阶段和超出上限。 ```ts type-equiv -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } ``` ## 请求与通知 -创建操作会区分调用方省略的值与部署选择,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。 +创建操作会区分调用方省略字段与采用部署配置值这两种情况,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。 ```ts type-equiv /** Input whose omitted round cap is resolved by the service configuration. */ @@ -133,7 +131,7 @@ interface EditGoalRequest { ``` ```ts type-equiv -/** Live notification after one goal mutation has been accepted for logging. */ +/** Live notification after one durable goal mutation commits. */ interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -144,4 +142,4 @@ interface GoalChanged { ## 服务行为 -[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、叠加延迟注入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 24f16f9781..7168f9bd85 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md -llm-streaming.md: 28f775f81049d3aa0198e75fbb6c4544302e772d -llm-streaming.zh.md: 76282018f0cfa119199e221985bd09eaac69c839 +llm-streaming.md: 5c90b3ce4ac65a99997f6ba7ad5deac494b7f772 +llm-streaming.zh.md: 7fd0043234cb40d6b21cec6ff101993164785a6e diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 28f775f810..5c90b3ce4a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -15,8 +15,9 @@ A streaming response interleaves several typed blocks (text, reasoning, multiple * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the * assembled block. Adapters emit usage before the terminal finish and nothing - * afterward; tool arguments remain raw JSON strings. Failures either throw or - * end with `error`/`aborted`, and consumers must handle both paths. + * afterward; tool arguments remain raw JSON strings. An adapter implementation + * may throw, but `LlmService.stream()` normalizes that failure to a terminal + * `error` or `aborted` finish before exposing it to consumers. */ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } @@ -64,10 +65,10 @@ Every adapter MUST obey these, and every consumer may rely on them: - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). -- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). +- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below), the `User-Agent` baseline. - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +Two independent implementations obey this contract: `dsh-llm-deepseek` uses direct fetch with SSE framing through `eventsource-parser`, while `dsh-llm-pi-ai` provides a generic multi-provider adapter through `@earendil-works/pi-ai`. Both carry cancellation and the idle watchdog to the provider request. ## `ResolvedRetryPolicy` @@ -141,8 +142,9 @@ declare class BlockAssembler { push(chunk: StreamChunk): void; /** * Assemble all blocks seen so far, in stream order. - * @returns one block per seen index; an open block assembles from its - * accumulated deltas (an unknown block type never closed by `block-end` throws). + * @returns one block per seen index, except that max-token truncation drops + * tool calls that cannot be executed safely; an open block assembles from + * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ @@ -169,6 +171,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Immutable retry policy captured with the adapter registration. */ + readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext /** Config fields materialized by the captured adapter rather than proposed by the caller. */ diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 76282018f0..7fd0043234 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -15,8 +15,9 @@ * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the * assembled block. Adapters emit usage before the terminal finish and nothing - * afterward; tool arguments remain raw JSON strings. Failures either throw or - * end with `error`/`aborted`, and consumers must handle both paths. + * afterward; tool arguments remain raw JSON strings. An adapter implementation + * may throw, but `LlmService.stream()` normalizes that failure to a terminal + * `error` or `aborted` finish before exposing it to consumers. */ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } @@ -64,10 +65,10 @@ interface LlmFailure { - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。 -- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 +- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送下文的 `attributionHeaders()`,即 `User-Agent` 基线。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +两个彼此独立的实现遵循该契约:`dsh-llm-deepseek` 使用直接 fetch,并通过 `eventsource-parser` 进行 SSE(Server-Sent Events)分帧;`dsh-llm-pi-ai` 则通过 `@earendil-works/pi-ai` 提供通用多提供方适配器。两者都会把取消与空闲 watchdog 传递至提供方请求。 ## `ResolvedRetryPolicy` @@ -75,7 +76,7 @@ interface LlmFailure { ## `AppIdentity`:应用归属 -每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包(package) manifest(元数据清单)获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包 manifest(元数据清单)获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ```ts type-equiv /** @@ -141,8 +142,9 @@ declare class BlockAssembler { push(chunk: StreamChunk): void; /** * Assemble all blocks seen so far, in stream order. - * @returns one block per seen index; an open block assembles from its - * accumulated deltas (an unknown block type never closed by `block-end` throws). + * @returns one block per seen index, except that max-token truncation drops + * tool calls that cannot be executed safely; an open block assembles from + * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ @@ -169,6 +171,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Immutable retry policy captured with the adapter registration. */ + readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext /** Config fields materialized by the captured adapter rather than proposed by the caller. */ diff --git a/docs/core-data-structures/lsp.i18n.yaml b/docs/core-data-structures/lsp.i18n.yaml index 5ab99680eb..5bed4a1a23 100644 --- a/docs/core-data-structures/lsp.i18n.yaml +++ b/docs/core-data-structures/lsp.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/lsp.md lsp.md: 62b133cbfdf521e067c56355664d7514a613397f -lsp.zh.md: d7000970ec9114bcdad40a39d2712d48b9865529 +lsp.zh.md: 51a19a51a8ad92e744cb920a51f3214f68ae0036 diff --git a/docs/core-data-structures/lsp.zh.md b/docs/core-data-structures/lsp.zh.md index d7000970ec..51a19a51a8 100644 --- a/docs/core-data-structures/lsp.zh.md +++ b/docs/core-data-structures/lsp.zh.md @@ -2,7 +2,7 @@ [English](lsp.md) | 中文 -LSP seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md):它在单一 `ctx.lsp` 服务上公开语义代码导航,并拆分到多个包(package):接口([dsh-lsp](../../packages/lsp/lsp),`ctx.lsp` + 提供方注册表)、通用实现([dsh-lsp-local](../../packages/lsp/lsp-local),经过配置的 stdio 语言服务器宿主)和消费方([dsh-tool-lsp](../../packages/lsp/tool-lsp),即 `lsp` 工具 schema)。LSP 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换提供方不会改变模型请求导航的方式。 +LSP seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md):它在单一 `ctx.lsp` 服务上公开语义代码导航,并拆分到多个包:接口([dsh-lsp](../../packages/lsp/lsp),`ctx.lsp` + 提供方注册表)、通用实现([dsh-lsp-local](../../packages/lsp/lsp-local),经过配置的 stdio 语言服务器宿主)和消费方([dsh-tool-lsp](../../packages/lsp/tool-lsp),即 `lsp` 工具 schema)。LSP 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换提供方不会改变模型请求导航的方式。 源文件:[`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) @@ -113,7 +113,7 @@ type LspQueryResult = ## 提供方与服务 -每个提供方拥有一个稳定的品牌化 `id`,以及一份互斥的、小写且以点开头的扩展名映射。`registerProvider` 会原子保留 id 和每个扩展名:注册无效或冲突时不发布任何内容;其 disposer 会释放所有保留项。每次查询独立选择提供方,且选择与顺序无关;没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。该 seam 不公开协议类型、进程或文档控制,也不提供通用 JSON-RPC 逃生口。 +每个提供方拥有一个稳定的品牌化 `id`,以及一份互斥的、小写且以点开头的扩展名映射。`registerProvider` 会原子预留 id 和每个扩展名:注册无效或冲突时不发布任何内容;其 disposer 会释放所有保留项。每次查询独立选择提供方,且选择与顺序无关;没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。该 seam 不公开协议类型、进程或文档控制,也不提供通用 JSON-RPC 逃生口。 ```ts type-equiv /** diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 9f42f7dab9..58321e0f22 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 237ce268691fa6f4b0546c6c1c14c21eaa462612 -persistence.zh.md: 9cb555f2e157844704896d9b04feb822428540e5 +persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37 +persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 237ce26869..0968496201 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -4,7 +4,7 @@ English | [中文](persistence.zh.md) The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, crash-repairing load, non-mutating inspect, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -14,9 +14,9 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the interrupted execution balanced without changing any standalone events before or after it. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). -Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it with the stored header only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. +Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` waits until the authoritative in-memory snapshot is durable and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. HMR adopts a live prefix without closing its active turn. -`SessionPersistence.inspect(id)` is the observer counterpart to recovery: it returns a detached valid stored prefix without truncating a torn record, adding interruption closers, or publishing write state. Same-id serialization keeps it coherent with backend writes. Derived read models use `inspect`, never `load`, so observing a checkpointed open turn cannot mutate the log if live ownership begins concurrently. +`SessionPersistence.inspect(id)` constructs an immutable logical Session without publishing it or writing recovery. Cold inspection balances an interrupted turn in memory while leaving torn physical tails untouched; inspection of an already-live Session borrows its current immutable snapshot and may therefore contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU, so repeated history reads and a later `prepare(id)` share one read, decompression, validation, freeze, and Session construction. `prepare(id)` reserves the Session, commits pending repair, and returns a disposable publication handle; `load(id)` uses the same machinery to commit repair without publication. The [Session preparation decision](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md) owns this lifecycle. ## `SessionLocation` — optional per-session artifact target @@ -82,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -110,6 +110,72 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## Preparation and restoration ownership + +`SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. + +```ts type-equiv +/** + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. + */ +interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' +} +``` + +```ts type-equiv +/** Inputs accepted while constructing an unpublished Session. */ +type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions +``` + +```ts type-equiv +/** Options for a preparation whose provider retains unpublished state. */ +interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} +``` + +```ts public-api +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +declare class SessionPreparation implements Disposable { + /** The exact Session to use for setup and publication. */ + readonly session: Session; + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation; + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void; +} +``` + +```ts type-equiv +/** Immutable logical session prepared from persistence or a live owner. */ +interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + readonly events: readonly SessionEvent[] +} +``` + ## Lightweight source revisions Consumers of derived state compare a cheap opaque revision before loading a full event log. The persistence backend owns its representation and changes it transactionally with append or mutating load repair; callers compare it only for equality. @@ -134,7 +200,7 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 9cb555f2e1..efb01427b4 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -4,7 +4,7 @@ 事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。 -该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、会执行崩溃修复的 load、不会修改数据的 inspect,以及轻量的 list/snapshot 观察——**没有平行的持久化类型**——以及两个可互换、通过同一套 `runPersistenceContract` 的后端。见 [session-persistence Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 +该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及两个实现同一契约的可互换后端。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 ## flush 检查点 @@ -14,9 +14,9 @@ 后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 -修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会对内存日志拍摄快照,等待该快照完成持久化,并且只在日志平衡时连同已存储的 header 返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。由协调器管理的冷加载会在后端读取和修复写入期间占用该 id,因此并发发布同 id 的活跃会话会被拒绝并回滚。HMR 也会接管活跃前缀,而不会关闭其中正在进行的轮次。 +修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会等待权威内存快照完成持久化,并且只在日志平衡时返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。HMR 会接管活跃前缀,而不会关闭其中正在进行的轮次。 -`SessionPersistence.inspect(id)` 是恢复机制面向观察方的对等操作:它返回已存储有效前缀的独立副本,不截断不完整记录、不添加中断结束事件,也不发布写入状态。同 id 串行化确保它与后端写入保持一致。派生读取模型使用 `inspect`,绝不使用 `load`,因此即使活跃所有权并发建立,观察已落检查点但仍未闭合的轮次也不会修改日志。 +`SessionPersistence.inspect(id)` 会构造一个不可变的逻辑 Session,但不发布它,也不写入恢复内容。冷检查会在内存中配平中断的 turn,同时保持撕裂的物理尾部不变;检查已经实时存在的 Session 则借用其当前不可变快照,因此可能包含打开的 turn。使用协调器的实现会在有界 LRU 中保留这个精确的冷未发布 Session,因此重复历史读取与后续 `prepare(id)` 可复用同一次读取、解压、验证、冻结及 Session 构造。`prepare(id)` 会预留该 Session、提交待处理修复并返回可 dispose 的发布句柄;`load(id)` 使用相同机制提交修复,但不会发布 Session。该生命周期由 [Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)定义。 ## `SessionLocation`——可选的逐会话产物目标 @@ -82,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -110,6 +110,72 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## 准备与恢复所有权 + +`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 + +```ts type-equiv +/** + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. + */ +interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' +} +``` + +```ts type-equiv +/** Inputs accepted while constructing an unpublished Session. */ +type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions +``` + +```ts type-equiv +/** Options for a preparation whose provider retains unpublished state. */ +interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} +``` + +```ts public-api +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +declare class SessionPreparation implements Disposable { + /** The exact Session to use for setup and publication. */ + readonly session: Session; + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation; + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void; +} +``` + +```ts type-equiv +/** Immutable logical session prepared from persistence or a live owner. */ +interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + readonly events: readonly SessionEvent[] +} +``` + ## 轻量源修订号 派生状态的消费方会在加载完整事件日志之前比较一个低开销的不透明修订号。其表示由持久化后端拥有,并随 append 或会修改数据的 load 修复以事务方式改变;调用方仅比较修订号是否相等。 @@ -134,7 +200,7 @@ interface SessionPersistenceSnapshot { ## 后端 -两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/inspect/list/listSnapshots),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: +两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 diff --git a/docs/core-data-structures/pty.i18n.yaml b/docs/core-data-structures/pty.i18n.yaml index 6fa7d71d13..11788e8e76 100644 --- a/docs/core-data-structures/pty.i18n.yaml +++ b/docs/core-data-structures/pty.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/pty.md pty.md: 97e1e662d1128ab0555e34f8284cf69d7d9d0d1a -pty.zh.md: b17bc0d2c7bdb2a980df36824bd360ea975967f5 +pty.zh.md: a57f7448274de583dbb110ba9499e15dfb4de5f9 diff --git a/docs/core-data-structures/pty.zh.md b/docs/core-data-structures/pty.zh.md index b17bc0d2c7..a57f744827 100644 --- a/docs/core-data-structures/pty.zh.md +++ b/docs/core-data-structures/pty.zh.md @@ -2,11 +2,11 @@ [English](pty.md) | 中文 -PTY 后端、`ctx.pty` 与面向模型的消费方共享的类型。[持久 PTY Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 负责记录决策依据;本页记录来自 [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts) 的跨包(package)词汇。 +PTY 后端、`ctx.pty` 与面向模型的消费方共享的类型。[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 负责记录决策依据;本页记录来自 [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts) 的跨包词汇。 ## 标识与就绪 -`PtySessionId` 是由服务铸造的品牌化 id。可选名称是拥有者本地的显示元数据;授权比较的是确切的所属 `Agent`,而不是名称或猜测的 id。 +`PtySessionId` 是由服务铸造的品牌化 id。可选名称是拥有者本地的显示元数据;授权比较的是拥有该会话的确切 `Agent`,而不是名称或猜测的 id。 `PtyWaitReason` 说明一次发送为何返回。它与 `PtySessionStatus` 无关:一次发送可能因静默或超时而返回,但顶层 shell 仍然存活;`session_exit` 表示该 shell 已退出,而不是某个任意的前台子进程已退出。 @@ -24,7 +24,7 @@ type PtySessionStatus = ## 后端与活跃会话 -后端负责某个已注册类型的启动方式和就绪检测。`PtyService` 只在初始化成功后才发布返回的会话,随后负责 id 授权与清理。无法清理部分启动资源的后端会以 `PtyBackendCleanupError` 拒绝,从而让资源释放流程保留该清理失败,同时不替换调用方的取消原因。后端会话拥有终端状态,并负责使已捕获资源完全停稳。 +后端负责启动某种已注册类型的会话并检测其就绪状态。`PtyService` 只在初始化成功后才发布返回的会话,随后负责 id 授权与清理。无法清理部分启动资源时,后端会以 `PtyBackendCleanupError` 拒绝启动;这样,资源释放流程既能保留清理失败,也不会用它替换调用方的取消原因。后端会话拥有终端状态,并负责让已捕获的资源完全停稳。 ```ts type-equiv /** Replaceable provider for one PTY session type. */ @@ -58,7 +58,7 @@ interface PtyBackendSession { ## 发送与保留输出 -一个活跃会话同时只接受一个活动发送。该操作向通用后台任务公开一个消费式输出游标,并向前台调用方公开一个最终结果。`PtyReadResult` 则为有界的会话 scrollback 单独分页。 +一个活跃会话同时只接受一个活动发送。该操作向通用后台任务提供读取后即推进的输出游标,并向前台调用方提供最终结果。`PtyReadResult` 则为有界的会话 scrollback 单独分页。 ```ts type-equiv /** Live backend-owned send; exactly one may be active per PTY session. */ @@ -88,4 +88,4 @@ interface PtySendResult { ## 归属与持久性 -`PtyService` 会将一项等待完成的清理附加到确切的拥有者作用域,拒绝其他拥有者的操作,并让会话在后端或工具插件重载期间保持存活。PTY 状态与原始字节仍局限在进程内。模型输入与有界的返回输出通过现有 `tool/call`、`tool/result` 和任务结果路径持久保存,而不是重复记录 PTY 会话事件。 +`PtyService` 会将一项等待完成的清理附加到确切的拥有者作用域,拒绝其他拥有者的操作,并让会话在后端或工具插件重载期间保持存活。PTY 状态与原始字节仍局限在进程内。模型输入与有界返回输出通过现有 `tool/call`、`tool/result` 和任务结果路径持久保存,而不是重复记录 PTY 会话事件。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index f8189f4e15..34691ad25b 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 +# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md +sandbox.md: 9e5feafe046f18dad49aeaf281793f0b8e03c240 +sandbox.zh.md: a1314d1b78eb0d46ea4c8ca5aa330ee83bf89132 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..9e5feafe04 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## Wrapped argv and classification dialects -`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. +`RunnerFailureRule` combines evidence that a runner failed before executing the command. A consumer requires a nonzero exit, the optional allowed-exit-code gate, and a case-insensitive fatal signature within one remaining stderr line. Case-insensitive exact full-line informational exclusions are removed first, so a benign runner notice cannot prove failure by itself. The matched line remains available as error detail; classification does not rewrite stderr. + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr classifiers. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureRules` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -An operator-configured local runner must supply at least one `runnerFailureSignatures` entry for its own pre-exec refusal dialect; the provider adds outer-shell missing and unexecutable forms automatically. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. +The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operator configuration and maps its runner dialect into these rules. The [sandboxed bash consumer](../../packages/bash/bash-sandbox/README.md) owns spawn and result attribution. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case its failure signature carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. -Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. +Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md). diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 9a52f12675..a1314d1b78 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -2,7 +2,7 @@ [English](sandbox.md) | 中文 -[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'> ``` -强制执行程度是一个报告事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 ```ts type-equiv /** @@ -68,7 +68,7 @@ interface SandboxPolicyRequest { } ``` -只有受约束的执行会到达 `ctx.sandbox`;其提供方策略在保留同一 root 的同时收窄模式。这使并发会话、消费方与一次性提权重试可以向同一提供方请求不同边界,而无需改变提供方状态。 +只有受约束的执行会到达 `ctx.sandbox`;传给提供方的策略在保留同一 root 的同时收窄模式。这使并发会话、消费方与一次性提权重试可以向同一提供方请求不同边界,而无需改变提供方状态。 ```ts type-equiv /** @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## 包装后的 argv 与分类方言 -`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 方言。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 用于识别沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 +`RunnerFailureRule` 汇集用于判定 runner 在执行命令前失败的证据。消费方要求进程以非零状态退出,并同时满足可选的允许退出码门控,以及余下某一 stderr 行中不区分大小写的致命签名。系统会先按不区分大小写的整行精确匹配移除信息性排除项,因此无害的 runner 通知本身不能证明失败。匹配到的行仍可用作错误详情;分类过程不会重写 stderr。 + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 分类器。`denialSignatures` 用于识别沙箱正常工作时受限命令被阻止的情况。`runnerFailureRules` 用于识别沙箱 runner 在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 +[本地提供方](../../packages/sandbox/sandbox-local/README.md)拥有运维配置,并将其 runner 方言映射到这些规则。[沙箱化 bash 消费方](../../packages/bash/bash-sandbox/README.md)拥有 spawn 与结果归因。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方契约。对于受限策略,静默的无隔离透传永远不合法。 -提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 +提供方选择、探测、缓存和后端专有的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.md)所有。 diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index b565e11461..aef528553d 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/scope.md scope.md: 73a697f2843293daffff85dabf4656346f7dcd04 -scope.zh.md: f3c591da2befdcff69d89ad0667653392111fb8e +scope.zh.md: 06e2528eca958102110caa3a7d370e0778c0f7b2 diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index f3c591da2b..06e2528eca 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -2,7 +2,7 @@ [English](scope.md) | 中文 -[scope 包(package)](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册上下文同时代表逐 agent(智能体)可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,注册表层决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 +[scope 包](../../packages/core/scope)提供身份、载体与作用域层词汇,使同一注册上下文同时表达每个 agent(智能体)的可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,注册表层决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 @@ -28,7 +28,7 @@ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } ## 拥有所有权的注册上下文 -`Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的精确 Cordis disposer 身份;`dispose()` 是面向直接调用方和竞态调用方的公共停稳边界。 +`Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的 Cordis disposer 本身;`dispose()` 是面向直接调用方和竞态调用方的公共完全停稳边界。 ```ts type-equiv /** A minted registration scope and its quiescent disposal boundaries. */ @@ -54,6 +54,6 @@ interface ScopeLayer { } ``` -`ScopedLayers<L>` 拥有立即创建的全局 layer,以及惰性创建的确切作用域 layer。读取不会创建 layer:`peek(undefined)` 表示没有 overlay,而 `merge()` 会物化按插入顺序排列的全局具名 entry,随后是带作用域的 shadow。注册使用同一个上下文表示可见性与 Cordis effect 所有权,在可选通知前收集一个同步 undo,返回 Cordis 的确切 disposer,并且只在带作用域 layer 的完整 `ScopeLayer` 为空时回收它。 +`ScopedLayers<L>` 拥有立即创建的全局 layer,以及惰性创建的确切作用域 layer。读取不会创建 layer:`peek(undefined)` 表示不存在作用域覆盖层,而 `merge()` 会依次物化按插入顺序排列的全局具名条目和带作用域的遮蔽项。注册使用同一个上下文表示可见性与 Cordis effect 所有权,在可选通知前取得一个同步撤销函数,返回 Cordis 的原始 disposer,并且只在带作用域 layer 的完整 `ScopeLayer` 为空时回收它。 -`NamedEntries<V>` 提供按插入顺序的查找与 live iteration,重复错误由调用方所有。`AnonymousEntries<V>` 为每次 append 分配唯一标识,使相等的值仍相互独立。迭代在同一非空 table generation 内保持 live;排空 table 会让现有 iterator 与后续插入脱离。两者都返回幂等的确切 entry undo;共享的 `EntryValues` 实现接口不公开。 +`NamedEntries<V>` 提供按插入顺序的查找和动态迭代,重复项错误由调用方处理。`AnonymousEntries<V>` 为每次 append 分配唯一标识,因此值相等的条目仍彼此独立。在同一轮非空 table 生命周期内,迭代器可以观察后续变化;table 被清空后,现有迭代器不会再观察后续插入。两者都返回幂等、精确对应相应条目的撤销函数;共享实现接口 `EntryValues` 不对外公开。 diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index f9c7355148..eca4715ba9 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e -session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d +# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md +session-query.md: e7514dd6c3bc20a07395663bff40ce65e1363b78 +session-query.zh.md: 4c3dd4d435dbd8a20fbd4db5da1a7d649c2e6d0b diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index d92af4bac3..e7514dd6c3 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -338,6 +338,7 @@ The closed code union distinguishes request validation, missing targets, malform /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index ecf330b0a3..4c3dd4d435 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -2,13 +2,13 @@ [English](session-query.md) | 中文 -本文定义面向优先使用 live 数据的逻辑会话语料库的查询词汇。[接口包(package)](../../packages/session-query/session-query)负责精确读取、来源优先级、关系追踪、语义提取,以及与提供方无关的过滤器;[SQLite 包](../../packages/session-query/session-query-sqlite)负责具体全文索引的生命周期。 +本文定义逻辑会话语料库的查询词汇;当 live 数据存在时,该语料库优先使用 live 数据。[接口包](../../packages/session-query/session-query)负责精确读取、来源优先级、关系追踪、语义提取,以及与提供方无关的过滤器;[SQLite 包](../../packages/session-query/session-query-sqlite)负责具体全文索引的生命周期。 源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) ## 逻辑记录 -`SessionRecord` 由跨语料库列表返回。它独立于克隆后的实时优先 header 暴露源可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与 model-history 推导相同的 `foldSurface()` 状态转换。 +`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换。 ```ts type-equiv /** Whether an event is current model context, replaced context, or raw-log-only. */ @@ -51,7 +51,7 @@ interface SessionSurfaceSnapshot { } ``` -`SessionTitleObservation` 将同样的原子观测规则应用于标题折叠,使授权消费者能够验证提供标题的源 header。批量读取会按顺序为每个唯一请求 id 返回一个 `SessionTitleObservationResult`:操作失败只影响对应 id,而取消会拒绝整个操作。 +`SessionTitleObservation` 将同样的原子观测规则应用于标题折叠,使执行授权检查的消费方能够验证提供标题的源 header。批量读取会按顺序为每个唯一请求 id 返回一个 `SessionTitleObservationResult`:操作失败只影响对应 id,而取消会拒绝整个操作。 ```ts type-equiv /** Latest folded title bound to the same session-header observation. */ @@ -102,7 +102,7 @@ interface SessionEventRecord { ## 与提供方无关的过滤器和文档 -会话和事件过滤器数组内的各项按逻辑与(AND)组合;单个列表子句中的各值按逻辑或(OR)组合。范围包含两端。事件的 `text` 子句会对提取出的语义文本执行正则表达式扫描:搜索文本按字面量处理,Unicode 字符不区分大小写,空白字符可灵活匹配;该过程与全文搜索提供方无关。 +会话和事件过滤器数组内的各项按逻辑与(AND)组合;单个列表子句中的各值按逻辑或(OR)组合。范围包含两端。事件的 `text` 子句会对提取出的语义文本执行正则表达式扫描:搜索文本按字面量处理,按 Unicode 规则执行不区分大小写的匹配,并允许灵活匹配空白字符;该过程与全文搜索提供方无关。 ```ts type-equiv /** @@ -191,7 +191,7 @@ interface SessionSearchPage<T> { } ``` -与跨会话分组 hit 不同,会话内搜索即使没有命中项,也必须公开它观测到的目标 header。 +与跨会话分组 hit 不同,会话内搜索结果即使没有命中项,也必须公开搜索时观测到的目标 header。 ```ts type-equiv /** Event-search results bound to the indexed target-session observation. */ @@ -219,7 +219,7 @@ interface SessionSearchHit extends SessionRecord { ## 会话谱系 -`SessionLineageTrace` 按由近及远的顺序携带已知 parent,并携带一片由直接 descendant 递归嵌套而成的森林。完整性判别字段使已知 root 与缺失 parent 互斥。 +`SessionLineageTrace` 按由近及远的顺序携带已知 parent,以及由直接 descendant 递归嵌套而成的森林。完整性判别字段使已知 root 与缺失 parent 互斥。 ```ts type-equiv /** Recursive descendant node in a session-lineage trace. */ @@ -292,7 +292,7 @@ interface SessionEventWindow { ## 事件关系 -事件追踪会区分位置性的 surface 替换与已记录 provenance。除 `replacementChain` 外,每个 seq 列表都包含直接链接;该链从目标沿直接 replacer 追踪到最终的位置替换。 +事件追踪会区分位置替换与日志中记录的来源关系。除 `replacementChain` 外,每个 seq 列表都只包含直接链接;该链从目标沿直接 replacer 追踪到最终的位置替换。 ```ts type-equiv /** Request for direct surface and provenance relationships around one event. */ @@ -338,6 +338,7 @@ interface SessionEventTraceObservation extends SessionEventTrace { /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml index 7e4d3c5408..85bbcf8877 100644 --- a/docs/core-data-structures/session-reference.i18n.yaml +++ b/docs/core-data-structures/session-reference.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session-reference.md session-reference.md: 5375677f6a1748909743ca76d5191cb9e736a40a -session-reference.zh.md: 8e9abea7ce87e51061813d282e20db951918a650 +session-reference.zh.md: 3ff4a1719926bda0a9111482a7778a8c94553370 diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md index 8e9abea7ce..3ff4a17199 100644 --- a/docs/core-data-structures/session-reference.zh.md +++ b/docs/core-data-structures/session-reference.zh.md @@ -2,7 +2,7 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与预备消息上下文。[包(package)契约](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +结构化的跨会话引用请求与准备后的消息上下文。[包契约](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) @@ -36,9 +36,9 @@ interface SessionReferenceCandidate { } ``` -## 预备消息 +## 准备后的消息 -预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 +准备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 ```ts type-equiv /** Direct message content and optional referenced-session context. */ @@ -52,7 +52,7 @@ interface PreparedReferencedMessage { ## 错误 -`SessionReferenceError.code` 区分无效配置或输入、自引用、数量限制、源读取失败、预算失败和取消。宿主协议会把这些 code 映射到各自的错误信封,无需检查提示词字节。 +`SessionReferenceError.code` 区分无效配置或输入、自引用、数量限制、源读取失败、预算失败和取消。宿主协议会把这些 code 映射到各自的错误封装,无需检查提示词字节。 ```ts type-equiv /** Stable failure codes exposed to host adapters. */ diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index ab368c4c37..67f9ac765b 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f -session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b +session-title.zh.md: 77a0a5e94053c94bf84bbc749cb6e260898b5d00 diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index 73821b07c6..77a0a5e940 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/docs/core-data-structures/session-title.zh.md @@ -2,13 +2,13 @@ [English](session-title.md) | 中文 -[`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title) 所拥有的持久化后写覆盖标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包(package)README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.md)负责完整的事件声明。 +[`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title) 所拥有的持久、后写覆盖的标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包 README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.md)负责完整的事件声明。 源码:[`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts)、[`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) ## 持久标题状态 -提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 携带精确的人类消息来源信息,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件信封事实。 +提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 携带精确的人类消息来源信息,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件封装信息。 ```ts type-equiv /** Identifies one session-title provider registration. */ @@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData { ## 提供方输入与输出 -服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接受过程会验证顺序、规范化标题、强制执行字节上限并追加来源信息。 +服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限并追加来源信息。 ```ts type-equiv /** One eligible human text message exposed to title providers. */ diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 773df07254..87f705345c 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 6369c956df03c0d786db696c208b000300d5bfbc -session.zh.md: c39382b7e6c9b14f91c311cc80526a6fd8898e4c +session.md: fac201b581e395865fd46d51bca1350cbbc46e8e +session.zh.md: 53dc9d11de895deec72aaf5ea81c70ba87c9c6bd diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6369c956df..fac201b581 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -26,14 +26,19 @@ interface UserMessage extends Message { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn` before the loop claims queued input or runs pre-step. + * Rejection, empty input, cancellation, or failure may close it with no + * step; otherwise the following identified `user/message` event or batch + * records the messages entering the step. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** - * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * awaits `session/flush` after an ordinary turn ends before claiming the next - * queued item. Success commits the turn; rejection is reported live and does - * not prevent later work. + * Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn + * with no entered step has no `step/start` or `step/end`. The loop does not await a + * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the + * per-request durability checkpoint, and consumers that read storage after + * `whenIdle()` flush themselves. Success commits the turn; rejection is + * reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ @@ -44,9 +49,8 @@ interface SessionEventMap { * A user-role message on the model-visible surface: a direct human prompt * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron - * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * notifications, …), or an entered goal continuation round. All three + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ @@ -82,8 +86,6 @@ interface SessionEventMap { error?: { name: string; code: string } meta?: JsonValue } - /** Steering content injected between steps of a running turn. */ - 'steering/message': { turn: number; message: UserMessage } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -92,21 +94,14 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context metadata for the route a request resolved to, - * appended inside its step beside `request/header` and only when the route - * or capacity differs from the last record. It is log-only and deliberately - * NOT part of {@link EpochHeader}: capacity is adapter metadata about a - * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. `contextWindow` is absent - * when the route's adapter advertises no capacity. + * Route metadata for the next request, logged only when the route or capacity + * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle - * produced none of them. An explicitly supplied empty seed puts the marker - * at seq 0, distinguishing an empty resumed session from a fresh session. - * This log-only event is the durable projection of + * produced none of them. This log-only event is the durable projection of * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` * carry the meaning. * @@ -183,17 +178,13 @@ Canonical form represents an empty system prompt or tool list as an absent field The context metadata of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, or capacity differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals`: capacity describes a route, not a request input, so folding it in would let a capacity change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header`, it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, so the new record clears an older route's capacity. ```ts type-equiv -/** - * Registration-bound context metadata of one resolved model route. Adapter - * metadata about a route rather than a request input, which is why it lives - * outside {@link EpochHeader}. - */ +/** Registration-bound metadata for one resolved model route. */ interface RequestContext { - /** Registered provider route the metadata was resolved through. */ + /** Registered provider route the metadata belongs to. */ provider: string /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + /** Maximum combined request and response context in tokens, when advertised. */ contextWindow?: number } ``` @@ -211,7 +202,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions), * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -245,7 +236,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp ## Surface types -The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). +The three message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -259,7 +250,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'steering/message' ``` ### `SurfaceOp` — how an event entered the surface @@ -269,7 +259,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -311,6 +301,8 @@ The same provenance distinction applies here: only `assistant/message` may carry `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. +`SurfaceManager(log, baseSeq?)` can instead fold a contiguous loaded window whose first event has the absolute sequence `baseSeq`. Every event remains contiguous in that absolute sequence space, and a replacement that crosses the window head fails because its declared range is absent. + ```ts type-equiv /** Readonly live projection of the message-producing session events. */ interface SessionSurface { @@ -351,13 +343,14 @@ interface SurfaceFoldResult { ## `Session` public API -The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). +The body-stripped declaration keeps the plain class's detached factory, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). ```ts public-api /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * - * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Plain class (not a Service) — create live instances via + * `ctx.sessions.create()` and detached instances via {@link create}. * Seeding with an existing event log replays/forks a session. * @typert object */ @@ -367,7 +360,7 @@ declare class Session { /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a - * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. @@ -384,9 +377,7 @@ declare class Session { * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage * boundary: a resumed session's constructor seed is its full stored log, * while its header keeps the original fork value — this field is the - * in-process construction fact. An explicitly supplied empty seed has the - * same value as no seed (0); its `session/end-seed` event preserves the - * lifecycle distinction. + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the * `session/end-seed` event, which is what a consumer reading STORED history @@ -400,7 +391,25 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * Create a detached session by validating and snapshotting borrowed seed + * events and storage metadata. + * @param id - session identity. + * @param seed - optional borrowed replay or fork events. + * @param header - optional borrowed storage metadata. + * @returns a detached session. + */ + static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** * An immutable snapshot of the append-only event log. The snapshot is reused * until the next append; a previously returned array does not grow later. @@ -460,11 +469,9 @@ declare class Session { */ requestHeader(): EpochHeader | undefined; /** - * The route metadata in force after the log's last `request/context` event — - * what the NEXT request deduplicates against — or undefined before any such - * record. Maintained incrementally like {@link requestHeader}, so a per-step - * read costs O(new events). - * @returns the folded context record, or undefined when none exists yet. + * Return the latest resolved route metadata, or `undefined` before the first + * `request/context` event. Each event is folded once. + * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; /** @@ -487,15 +494,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ @@ -511,9 +511,8 @@ declare class Session { - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. - `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. -- `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. -Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. +Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. ## Live-session fork API @@ -523,29 +522,14 @@ Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. -## What started a turn: `TurnTriggerMap` - -```ts type-equiv -/** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). - */ -interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } -} -``` - ## Why a turn ended: `TurnEndReasonMap` -`aborted` is intentionally a coarse durable outcome: it records that cancellation interrupted the live turn, not which runtime caller requested it. The runtime-only caller vocabulary belongs to [`AgentCancelCause`](core.md#the-agent-handle); a future audit requirement would use a separate control-request event rather than overloading the terminal result. +`turn/start` has no trigger field. The entered `user/message` batch records what entered each step, `llm/retry` records request recovery, and idle injection remains pending until a waking delivery reaches a later pre-step. Live turns retain the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver; persistence uses the additional `{ kind: 'legacy' }` cause only when importing a supported coarse cancellation record that did not store its caller. + +```ts type-equiv +/** Durable cancellation cause, including imports whose original coarse record carried no cause. */ +type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' } +``` ```ts type-equiv /** @@ -554,20 +538,15 @@ interface TurnTriggerMap { interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: TurnEndCancelCause } + + blocked: { kind: 'blocked' } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. `error` is always a structured failure: the `LlmError` + * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }` + * flattened from any other error. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: LlmFailure } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -578,11 +557,11 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. Cancellation and errors remain distinct outcomes. `interrupted` is the one reason no loop emits—it is synthesized by crash recovery (see [persistence.md](persistence.md)). The map is merge-extensible. ## Execution enclosure and standalone events -A turn encloses one model-loop execution, not the whole session log. Idle injected `user/message` events and plugin-owned log-only events may appear between `turn/end` and the next `turn/start`; they consume event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. +A turn encloses one model-loop execution, not the whole session log. AgentLoop records injected `user/message` events only from entering pre-step batches inside a turn; plugin-owned log-only events may still appear between `turn/end` and the next `turn/start`, consuming event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). @@ -600,7 +579,7 @@ Activity ordering excludes the boundary through `lastActivityTime(events)`: pick A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index c39382b7e6..53dc9d11de 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -26,14 +26,19 @@ interface UserMessage extends Message { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn` before the loop claims queued input or runs pre-step. + * Rejection, empty input, cancellation, or failure may close it with no + * step; otherwise the following identified `user/message` event or batch + * records the messages entering the step. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** - * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * awaits `session/flush` after an ordinary turn ends before claiming the next - * queued item. Success commits the turn; rejection is reported live and does - * not prevent later work. + * Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn + * with no entered step has no `step/start` or `step/end`. The loop does not await a + * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the + * per-request durability checkpoint, and consumers that read storage after + * `whenIdle()` flush themselves. Success commits the turn; rejection is + * reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ @@ -44,9 +49,8 @@ interface SessionEventMap { * A user-role message on the model-visible surface: a direct human prompt * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron - * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * notifications, …), or an entered goal continuation round. All three + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ @@ -82,8 +86,6 @@ interface SessionEventMap { error?: { name: string; code: string } meta?: JsonValue } - /** Steering content injected between steps of a running turn. */ - 'steering/message': { turn: number; message: UserMessage } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -92,21 +94,14 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context metadata for the route a request resolved to, - * appended inside its step beside `request/header` and only when the route - * or capacity differs from the last record. It is log-only and deliberately - * NOT part of {@link EpochHeader}: capacity is adapter metadata about a - * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. `contextWindow` is absent - * when the route's adapter advertises no capacity. + * Route metadata for the next request, logged only when the route or capacity + * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle - * produced none of them. An explicitly supplied empty seed puts the marker - * at seq 0, distinguishing an empty resumed session from a fresh session. - * This log-only event is the durable projection of + * produced none of them. This log-only event is the durable projection of * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` * carry the meaning. * @@ -133,7 +128,7 @@ interface SessionEventMap { ### `TodoItem`:一条待办项 -这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 +这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识。见 [todo_write Agent Note](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 ```ts type-equiv /** @@ -185,17 +180,13 @@ interface EpochHeader { 请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建契约:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 ```ts type-equiv -/** - * Registration-bound context metadata of one resolved model route. Adapter - * metadata about a route rather than a request input, which is why it lives - * outside {@link EpochHeader}. - */ +/** Registration-bound metadata for one resolved model route. */ interface RequestContext { - /** Registered provider route the metadata was resolved through. */ + /** Registered provider route the metadata belongs to. */ provider: string /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + /** Maximum combined request and response context in tokens, when advertised. */ contextWindow?: number } ``` @@ -213,7 +204,7 @@ interface RequestContext { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -247,7 +238,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { ## Surface 类型 -四种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 +三种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 ### `SurfaceEventType`:事件类型中产生消息的子集 @@ -261,7 +252,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'steering/message' ``` ### `SurfaceOp`:事件如何进入 surface @@ -271,7 +261,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -313,6 +303,8 @@ interface SurfaceIntent { `Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 +`SurfaceManager(log, baseSeq?)` 也可以折叠一个连续的已加载窗口,其第一个事件的绝对序号为 `baseSeq`。每个事件在该绝对序号空间中仍保持连续;如果替换跨过窗口头部,由于其声明的范围并不存在,该替换会失败。 + ```ts type-equiv /** Readonly live projection of the message-producing session events. */ interface SessionSurface { @@ -351,15 +343,16 @@ interface SurfaceFoldResult { } ``` -## `Session` public API +## `Session` 公共 API -去除方法体的声明与源码中的普通类保持同步,覆盖其公共构造函数、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。 +去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。 ```ts public-api /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * - * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Plain class (not a Service) — create live instances via + * `ctx.sessions.create()` and detached instances via {@link create}. * Seeding with an existing event log replays/forks a session. * @typert object */ @@ -369,7 +362,7 @@ declare class Session { /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a - * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. @@ -386,9 +379,7 @@ declare class Session { * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage * boundary: a resumed session's constructor seed is its full stored log, * while its header keeps the original fork value — this field is the - * in-process construction fact. An explicitly supplied empty seed has the - * same value as no seed (0); its `session/end-seed` event preserves the - * lifecycle distinction. + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the * `session/end-seed` event, which is what a consumer reading STORED history @@ -402,7 +393,25 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * Create a detached session by validating and snapshotting borrowed seed + * events and storage metadata. + * @param id - session identity. + * @param seed - optional borrowed replay or fork events. + * @param header - optional borrowed storage metadata. + * @returns a detached session. + */ + static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** * An immutable snapshot of the append-only event log. The snapshot is reused * until the next append; a previously returned array does not grow later. @@ -462,11 +471,9 @@ declare class Session { */ requestHeader(): EpochHeader | undefined; /** - * The route metadata in force after the log's last `request/context` event — - * what the NEXT request deduplicates against — or undefined before any such - * record. Maintained incrementally like {@link requestHeader}, so a per-step - * read costs O(new events). - * @returns the folded context record, or undefined when none exists yet. + * Return the latest resolved route metadata, or `undefined` before the first + * `request/context` event. Each event is folded once. + * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; /** @@ -489,15 +496,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ @@ -513,9 +513,8 @@ declare class Session { - `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 - `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;溯源信息与领域数据都在其类型化的 source 中。 -- `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 -其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 +其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 ## 活跃会话 fork API @@ -523,33 +522,18 @@ declare class Session { - `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 -显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 - -## 轮次的触发原因:`TurnTriggerMap` - -```ts type-equiv -/** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). - */ -interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } -} -``` +显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具调用时的委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 <a id="why-a-turn-ended-turnendreasonmap"></a> ## 轮次的结束原因:`TurnEndReasonMap` -`aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 +`turn/start` 没有 trigger 字段。返回 enter 的 pre-step 所产生的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则保持待处理,直到唤醒交付抵达后续 pre-step。实时轮次会保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle);只有在导入受支持的粗粒度取消记录且记录未保存调用方时,持久化才使用额外的 `{ kind: 'legacy' }` 原因。 + +```ts type-equiv +/** Durable cancellation cause, including imports whose original coarse record carried no cause. */ +type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' } +``` ```ts type-equiv /** @@ -558,20 +542,15 @@ interface TurnTriggerMap { interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: TurnEndCancelCause } + + blocked: { kind: 'blocked' } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. `error` is always a structured failure: the `LlmError` + * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }` + * flattened from any other error. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: LlmFailure } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -582,11 +561,11 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止。取消和错误仍是不同的结果。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。该 map 可通过合并扩展。 ## 执行封闭与独立事件 -一个轮次包围一次模型循环执行,而不是整个会话日志。空闲注入的 `user/message` 事件和插件所属的纯日志事件可以出现在 `turn/end` 与下一个 `turn/start` 之间;它们占用事件 seq,但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 +一个轮次包围一次模型循环执行,而不是整个会话日志。AgentLoop 只会从轮次内返回 enter 的 pre-step 批次记录注入的 `user/message` 事件;插件所属的纯日志事件仍可出现在 `turn/end` 与下一个 `turn/start` 之间,占用事件 seq 但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 @@ -604,10 +583,10 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 -持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型、破坏核心执行嵌套,或违反事件所有方声明的关系,都会构成磁盘格式的破坏性变更。 +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index f5c134815e..ea1c7a28bb 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md -skills.md: d4b41845bea009444653739abad712e9ce3afb13 -skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974 +skills.md: 16e6fb649f9db4d7be47468adf0bf8a00df428c3 +skills.zh.md: 7d69b84f7be200b795e7a58eb3de1f029a8024f5 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index d4b41845be..16e6fb649f 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -211,7 +211,7 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` injects the initial durable user-role `<system-reminder>` at the first `agent/step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. +`dsh-tool-skill` injects the initial durable user-role `<system-reminder>` at the first `agent/pre-step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `<available_skills>` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 8d67931290..7d69b84f7b 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 拆分为三个包:注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目、自定义和用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名项依次按 rank、提供方顺序和本地顺序确定优先级;摘要按名称排序。提供方的 `list()` 被拒绝时,系统会记录日志,并从不完整观测中省略该提供方的结果;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 `SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 @@ -61,7 +61,7 @@ interface SkillProviderControl { ## 本地发现优先级 -内置的本地提供方按 rank 顺序扫描各根目录: +随附的本地提供方按 rank 顺序扫描各根目录: | Rank | Source | Root | |---|---|---| @@ -76,7 +76,7 @@ interface SkillProviderControl { Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 -## Skill 身份 +## skill 身份 skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 @@ -185,7 +185,7 @@ type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & { ## 查找与配置 -skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收用于缓存标识和加载的同一个只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 @@ -211,8 +211,8 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/step` 注入初始的持久 user-role `<system-reminder>`。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 +`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/pre-step` 注入初始的持久 user-role `<system-reminder>`。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `<available_skills>` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它为调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将未解析的 skill 报告为 unknown 或 no longer available,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它根据调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将无法解析的 skill 报告为未知或已不可用,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/core-data-structures/spill.i18n.yaml b/docs/core-data-structures/spill.i18n.yaml index 4cd4cc5e1e..17f210bc85 100644 --- a/docs/core-data-structures/spill.i18n.yaml +++ b/docs/core-data-structures/spill.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/spill.md spill.md: a798d8143b2849dc0cf49d04e7019ce796cdee45 -spill.zh.md: 1af6939d1d8fd37958cae4f9cf2cbf706b17acd0 +spill.zh.md: 1167c6f985dbc204dc7166b0fb5854dcf55bc72f diff --git a/docs/core-data-structures/spill.zh.md b/docs/core-data-structures/spill.zh.md index 1af6939d1d..1167c6f985 100644 --- a/docs/core-data-structures/spill.zh.md +++ b/docs/core-data-structures/spill.zh.md @@ -2,7 +2,7 @@ [English](spill.md) | 中文 -落盘存储 seam 是一项[能力 seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),它持久保存工具的超大文本,并返回面向模型的定位符与检索指引;该能力拆分到三个包(package):接口([dsh-spill](../../packages/spill/spill),`ctx.spillStore`)、实现([dsh-spill-local](../../packages/spill/spill-local),宿主文件系统中会话作用域的私有文件)和消费方([dsh-spill-policy](../../packages/spill/spill-policy),`tools/post-execute` 策略)。落盘是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇记录在此处,而不在 [core.md](core.md) 中。预览机制仍归 [dsh-retention](../../packages/util/retention) 所有;该 seam 只保存策略交给它的最终文本。 +落盘存储 seam 是一项[能力 seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),它持久保存工具的超大文本,并返回面向模型的定位符与检索指引;该能力拆分到三个包:接口([dsh-spill](../../packages/spill/spill),`ctx.spillStore`)、实现([dsh-spill-local](../../packages/spill/spill-local),宿主文件系统中会话作用域的私有文件)和消费方([dsh-spill-policy](../../packages/spill/spill-policy),`tools/post-execute` 策略)。落盘是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇记录在此处,而不在 [core.md](core.md) 中。预览机制仍归 [dsh-retention](../../packages/util/retention) 所有;该 seam 只保存策略交给它的最终文本。 源码:[`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5de81fa45..a449388c60 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: c5fbf80ae71f99606dd86e38f06a4511b4ae4c73 -subagent.zh.md: 42c1fa7cb10863c1aa4ae975171b901207c08b85 +subagent.md: 956b47cfa85efe7826fbde47d4405d20a6abed6c +subagent.zh.md: 467fcd35bcde5fd01a2e18efbad862c72d7a4253 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c5fbf80ae7..956b47cfa8 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -149,6 +149,8 @@ Final settlement awaits `ctx.sessions.flush(session)` but ignores its participat /** Attribution for a model coordinator's follow-up to one of its children. */ interface CoordinatorMessageSource { readonly kind: 'coordinator' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the agent whose tool call produced the follow-up. */ readonly senderSessionId: SessionId } @@ -182,6 +184,8 @@ An optional continuable-child setup contribution can install scope-local capabil /** Durable attribution for a continuable child's explicit parent report. */ interface SubagentReportMessageSource { readonly kind: 'subagent-report' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the reporting child. */ readonly senderSessionId: SessionId } diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 42c1fa7cb1..467fcd35bc 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -149,6 +149,8 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` /** Attribution for a model coordinator's follow-up to one of its children. */ interface CoordinatorMessageSource { readonly kind: 'coordinator' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the agent whose tool call produced the follow-up. */ readonly senderSessionId: SessionId } @@ -182,6 +184,8 @@ interface ContinuableStart { /** Durable attribution for a continuable child's explicit parent report. */ interface SubagentReportMessageSource { readonly kind: 'subagent-report' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the reporting child. */ readonly senderSessionId: SessionId } diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..01a6d57110 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +subprocess.zh.md: ec9f41cfa9e3121f630bab2b6f1e921c81ad55b7 diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..ec9f41cfa9 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -1,8 +1,8 @@ -# 进程管理器 +# 子进程 [English](subprocess.md) | 中文 -进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 +子进程 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) @@ -32,7 +32,7 @@ interface CollectedOutput { } ``` -## Node 形状的 stdio 处置方式(disposition) +## Node 风格的 stdio 处置方式(disposition) 每条流的处置方式都显式给出,由各消费方自行选择:原始管道用于协议分帧(LSP JSON-RPC、ACP ndjson),inherit 用于直通的诊断输出,收集模式用于有界的批量输出;其中 spill 文件是可选的,因此诊断尾部(语言服务器的 stderr)可以只在内存中缓冲,不留下任何文件。 @@ -84,7 +84,7 @@ interface SubprocessStdio { ## 完全显式的 spawn spec -该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 +该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的子进程服务默认值决定。`argv` 绝不经过 shell 解释。 ```ts type-equiv /** @@ -127,7 +127,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 +spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树。这足以让消费方构建自己的拆卸阶梯;ACP 后端的 `disposeAcpChild` 以 stdin EOF 开始,即为仓库内模板。 ```ts type-equiv /** diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index 2984c73425..8d9cd0897f 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/system-prompt.md -system-prompt.md: 5abb8f46c13045c7d37bbe12ecf6c3744ee063b5 -system-prompt.zh.md: 1088b20ba4289ad5912a193eead39d069c1a6e17 +system-prompt.md: 59193c1881abcadbc8a1778cde92f5a6572eee24 +system-prompt.zh.md: 41e45417817895ccf6def70e510eca7422a65e41 diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 5abb8f46c1..59193c1881 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -66,15 +66,11 @@ interface PromptSection { `PromptContext` is the cache-safe counterpart to `PromptSection`. The assembly resolves and orders these contributions, while agent-loop logs their complete current snapshot after retained model history only when it changed or compaction removed it. ```ts type-equiv -/** - * One dynamic model-context contribution. Unlike a {@link PromptSection}, its - * rendered text is materialized as a durable user-role snapshot at the request - * tail, so changing runtime state preserves the stable system/history prefix. - */ +/** Dynamic model context materialized as a durable user-role snapshot. */ interface PromptContext { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */ readonly name: string - /** Contexts are joined in ascending order, independently of system-section order. */ + /** Contexts are joined in ascending order. */ readonly order: number /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index 1088b20ba4..41e4541781 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -2,13 +2,13 @@ [English](system-prompt.md) | 中文 -[system-prompt 包(package)](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 ## 组装上下文 -`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段,`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有 scope,也没有 signal。 +`AssembleContext` 标识一次组装所解析的作用域层,并可携带该请求的显式控制信号。它可合并扩展:`dsh-agent` 添加可选字段 `agent`,用于携带当前的 agent(智能体)实例;`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有作用域,也没有信号。 ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ @@ -25,7 +25,7 @@ interface AssembleContext { ## 工具提供方结果 -`ToolProviderResult.schemas` 是当前组装中对模型可见的工具集合。`knownNames` 是提供方在限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。 +`ToolProviderResult.schemas` 是当前组装中对模型可见的工具 schema 集合。`knownNames` 是提供方在限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。 ```ts type-equiv /** Tool schemas visible in one assembly and their pre-restriction name set. */ @@ -66,15 +66,11 @@ interface PromptSection { `PromptContext` 是与 `PromptSection` 对应的缓存安全结构。组装会解析这些贡献并排序;agent loop(智能体循环)仅在完整当前快照发生变化或被压缩(compaction)移除时,才会将其记录在保留的模型历史之后。 ```ts type-equiv -/** - * One dynamic model-context contribution. Unlike a {@link PromptSection}, its - * rendered text is materialized as a durable user-role snapshot at the request - * tail, so changing runtime state preserves the stable system/history prefix. - */ +/** Dynamic model context materialized as a durable user-role snapshot. */ interface PromptContext { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */ readonly name: string - /** Contexts are joined in ascending order, independently of system-section order. */ + /** Contexts are joined in ascending order. */ readonly order: number /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml index 3a5a45566b..58920a53df 100644 --- a/docs/core-data-structures/tasks.i18n.yaml +++ b/docs/core-data-structures/tasks.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/tasks.md tasks.md: a38055d3ef7aa18e62678f92eb5ac5ae2a09c205 -tasks.zh.md: b5dd7f75c7df3e359bc995fce57f1ca2dc7fd017 +tasks.zh.md: f34d42e713c3a0c11cbf88d52e573bb100c52493 diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md index b5dd7f75c7..f34d42e713 100644 --- a/docs/core-data-structures/tasks.zh.md +++ b/docs/core-data-structures/tasks.zh.md @@ -2,7 +2,7 @@ [English](tasks.md) | 中文 -长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 +长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 ## ID 与状态 diff --git a/docs/core-data-structures/token-meter.i18n.yaml b/docs/core-data-structures/token-meter.i18n.yaml index ed05739725..3f58d10153 100644 --- a/docs/core-data-structures/token-meter.i18n.yaml +++ b/docs/core-data-structures/token-meter.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/token-meter.md token-meter.md: 05784e294485a11acf0e4c8972e4083b1786c943 -token-meter.zh.md: c0dc55274acf21186f7baa00c377f9135792f888 +token-meter.zh.md: 0474d86188a111014e6d72e9962121c08a228d73 diff --git a/docs/core-data-structures/token-meter.zh.md b/docs/core-data-structures/token-meter.zh.md index c0dc55274a..0474d86188 100644 --- a/docs/core-data-structures/token-meter.zh.md +++ b/docs/core-data-structures/token-meter.zh.md @@ -2,7 +2,7 @@ [English](token-meter.md) | 中文 -`@deepseek-ai/dsh-token-meter` 公开一个独立的回放快照,用于表示请求压力与按位置计算的 surface 定价。`logRevision` 表示生成该计量中每个字段时所消费的持久事件数量。 +`@deepseek-ai/dsh-token-meter` 公开一个独立的回放快照,用于表示请求压力与按位置计算的表层定价。`logRevision` 表示生成该计量中每个字段时所消费的持久事件数量。 来源:[`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) @@ -26,7 +26,7 @@ interface TokenMeasurement { } ``` -`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求信封,且当前总量不低于该调用的完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和 surface 定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对 surface 的启发式总量,等于所有节点价格之和。 +`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求信封,且该调用的总量不低于其完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和表层定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对表层的启发式总量,等于所有节点价格之和。 ## `TokenSurfaceNode` @@ -40,4 +40,4 @@ interface TokenSurfaceNode { } ``` -surface 顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 +表层顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index 912c1decbe..e8ae658d3a 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/web.md web.md: 20d07240c9d9fea2f1f5abbac810f349a3e81f9b -web.zh.md: 68ceed04bb0b80f32ed704118f1fc25f48a0da70 +web.zh.md: e2982ba571353752e3a7d10130599e8c8fe941f8 diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index 68ceed04bb..e2982ba571 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -2,13 +2,13 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项能力**(search 与 fetch),并拆分到多个包(package):接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local))与消费方([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型请求 query 的方式,更换 fetch 实现也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项能力**(search 与 fetch),并拆分到多个包:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local))与消费方([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 实现也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) ## 为什么两项能力合为一个 seam -搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的所有者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、提示词引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 +搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的所有者、一套中止与错误词汇,以及一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而不是遗漏了可抽取的共性。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、提示词引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 ## 搜索请求与结果 @@ -50,7 +50,7 @@ interface WebSearchResult { } ``` -`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是一套可跨提供方使用的引用数据结构。一个 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非每个提供方都返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 +`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是一套可跨提供方使用的引用数据结构。每个来源都必须有 `url`;`title`、`snippet` 和 `publishedAt` 为可选字段,因为并非每个提供方都会返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 ```ts type-equiv /** @@ -82,7 +82,7 @@ interface WebFetchRequest { } ``` -HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:成功的网络抓取返回 `404`/`500` 时,仍产出一个带状态码和有界解码 body 的 `WebFetchResult`。`url` 是经过允许的重定向后的最终 URL。`WebError` 仅用于无法安全获取或表示资源的情况。 +HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:即使一次成功的网络抓取收到 `404` 或 `500` 响应,也仍会产出一个 `WebFetchResult`,其中包含状态码和长度受限的已解码正文。`url` 是经过允许的重定向后的最终 URL。`WebError` 仅用于无法安全获取或表示资源的情况。 ```ts type-equiv /** @@ -122,14 +122,14 @@ type WebFetchBody = ## 提供方可用性 -提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它以选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由——其 code 和 message 携带可分支的细节(缺失的 id 或有歧义的候选集)。 +提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。 -选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;多个可用提供方且未配置 id 时为 `WEB_PROVIDER_AMBIGUOUS`,而非先注册先赢。 +选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。 ## 错误 -`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的 code,消费方必须容忍未知 code。code 按所有者划分。seam 中立的 code 由 `WebService` 选择逻辑和共享契约抛出:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障通过 seam 暴露的兜底 code,包括网络/传输失败——DNS、连接被拒、TLS)。抓取传输层 code 由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。由 seam 统一定义的错误代码来自 `WebService` 的选择逻辑和共享契约:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 ## 服务 -`WebService` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数和时间、对每一跳同源重定向重新校验,并解码 body;展示由工具负责。私有网络阻断尚未实现,因此请勿在可触及敏感内部目标的环境中启用 `web_fetch`。 +`WebService` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数和时间、对每一跳同源重定向重新校验,并解码正文;展示由工具负责。SSRF/私有网络防护尚未实现,因此在能够触及敏感内部目标的环境中,禁止启用 `web_fetch`。 diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index 492a9bea08..7f996a1c8e 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/core-data-structures/workflow.md workflow.md: 8d271b89e71de6f6bef548aa8da61402ef9ada6e -workflow.zh.md: b8ed699eb52d9f0cef23c513f625de7e82c46c45 +workflow.zh.md: 91a4902bbc004e911c8aa84adb6a4abeda9dd59f diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index b8ed699eb5..91a4902bbc 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -2,15 +2,15 @@ [English](workflow.md) | 中文 -工作流 seam:一个 agent(智能体)运行由模型编写的编排脚本(SCRIPT),扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 +工作流 seam 允许 agent(智能体)运行由模型编写的编排脚本,并由该脚本扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 -接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 +接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) ## 启动请求 -调用方启动 run 时提出的请求。普通工作流工具根据模型的 `{ script, meta, args }` 调用与发起调用的 agent 构建它;专用消费方还可以为该 run 选择一个引擎级 `subagentProvider` 并调低 `maxTotalAgents`,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据(引擎会对 `meta` 做形状校验,并在任何内容运行前大声拒绝——绝不会通过求值脚本文本来获取它)。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 +本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会校验 `meta` 的形状,并在任何工作开始前大声拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 ```ts type-equiv /** @@ -72,7 +72,7 @@ interface WorkflowMeta { ## 终态结果:`WorkflowResult` -一次运行的结果,由 `WorkflowRun.result` resolve。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是封闭联合类型(引擎所有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 +`WorkflowRun.result` 会兑现为一次运行的结果。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是封闭联合类型(引擎所有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 ```ts type-equiv /** @@ -102,7 +102,7 @@ interface WorkflowResult { ## 活跃运行:`WorkflowRun` -脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且必须在每条路径上 `dispose`(资源释放)。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 等待子 agent 停稳;它不会因脚本卡死而挂起。 +脚本执行期间消费方持有的句柄。消费方会等待 `result`,可以在运行期间调用 `cancel`,并且必须在每条路径上调用 `dispose`(资源释放)。`result` 不会被拒绝:脚本失败会兑现为 `stopReason: 'error'`。运行被取消后,即使脚本本身永不结算,结果也会在引擎规定的有界宽限期内结算;引擎会强制将其结算为 `cancelled`,随后 worker-thread 引擎会终止脚本所在的 worker。因此,等待 `result` 的消费方不会在取消后无限期挂起。`dispose()` 会执行取消、等待有界结算并等待子 agent 完全停稳,不会因脚本卡死而挂起。 ```ts type-equiv /** diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 674cd63efe..539a3f396e 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/defensive-patterns.md -defensive-patterns.md: cc34877fb0d6a2e1740d8fa138f879363c8e69a3 -defensive-patterns.zh.md: 21b0977d8167ffecc21cdfab3c778efceefd8f03 +defensive-patterns.md: 6dc1708f0b9bbcb00ad4774006d6d60059accf6a +defensive-patterns.zh.md: 4c376f1a5f2f6d5fbe85124ce473bc7af5ad4c8d diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index cc34877fb0..6dc1708f0b 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -10,15 +10,15 @@ A result can be several things at once — a process can time out AND exit 0 bec ## Honor cross-seam contracts on BOTH sides -When an interface documents two valid ways to signal something — an adapter may report failure by THROWING from `stream()` or by ending the stream with a `finish {kind:'error'|'aborted'}` chunk — the consumer handles both, not just the one the first implementation used. A library-backed adapter that can't throw mid-stream relies on the in-band path; a loop that only catches throws turns a provider 401 into a normal completed turn. Document the contract where the type is defined; exercise every branch through the real consumer. +When an implementation boundary receives several representations of one outcome, normalize them before crossing the public seam. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. ## Async state is not synchronous state -`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.followup()` has no per-message completion or result; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it -A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. Tests prove disposal waited (pid gone right after `await fiber.dispose()`), not merely that the process eventually dies. +A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. ## Contain callback exceptions at the boundary diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index 21b0977d81..4c376f1a5f 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -2,28 +2,28 @@ [English](defensive-patterns.md) | 中文 -来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、world 验证、资源归属)见 [testing.md](testing.md)。 +来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、验证实际结果、资源归属)见 [testing.md](testing.md)。 ## 正交结果独立上报 -一个结果可以同时具有多重性质:进程可能既超时又以 exit 0 退出,因为它捕获了信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应独立暴露;切勿将某个 flag 的上报嵌套在另一个 flag 的分支内,否则调用方会把一次被截断的运行误读为正常成功。 +一个结果可以同时具有多种性质:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应单独上报;切勿把一个标志的上报嵌套在另一个标志的分支中,否则调用方可能把提前终止的运行误判为正常成功。 ## 跨 seam 契约两侧都要遵守 -当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 +当一个实现边界接收到同一结果的多种表示时,应在跨越公共 seam 前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止 finish chunk 暴露模型请求失败;middleware 与消费方缺陷仍会抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化契约;通过真实消费方覆盖每种来源形式。 ## 异步状态不是同步状态 -`agent.followup()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent 下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 ## Dispose 必须达到完全停稳,而不仅仅是请求停止 -一个清理流程如果发出 kill/abort 后就返回、而不等待工作实际停止,就会留下孤儿进程。请让清理逻辑异步化并 await 子进程退出(kill → await `done`),并在 kill 之前关闭监听器/通知注册表,使迟到的完成事件保持静默。测试应证明 dispose 确实等待了(`await fiber.dispose()` 之后 pid 已不存在),而不仅仅是进程最终会死。 +如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。 -## 在边界处包容回调异常 +## 在边界处隔离回调异常 用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 ## 绝不将环境变量或可预测路径暴露给不可信输出 -spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 +启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 619aa92d76..95ed34cce0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 77087a95fdb4cfbeb02111ef2560d6989cde5686 -development.zh.md: 18e130e2f3aa1c53f6efa70366cdb629df71ad85 +development.md: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e +development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d diff --git a/docs/development.md b/docs/development.md index 77087a95fd..30a2bd0a2c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,16 +2,18 @@ English | [中文](development.zh.md) -This onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs. +The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts. -## Prerequisites +## Setup tutorial + +### Prerequisites - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests. -## First-time setup +### First-time setup Install dependencies from the repo root: @@ -19,7 +21,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). +The install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract. If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: @@ -27,11 +29,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly. - -Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. - -After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract. +If the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path. Run typecheck once after a fresh clone: @@ -39,9 +37,13 @@ Run typecheck once after a fresh clone: pnpm run typecheck ``` -That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below. +Setup is complete when `pnpm run typecheck` exits successfully. -## TypeScript project layout +## Contributor reference + +### TypeScript project layout + +The repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates. The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. @@ -68,7 +70,7 @@ pnpm run build `pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it. -## Environment variables +### Environment variables The real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root: @@ -79,7 +81,7 @@ DEEPSEEK_BASE_URL=https://... # optional `DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set. -## Git hooks +### Git hooks lefthook is configured in `lefthook.yml` as a fast local checkpoint: @@ -92,44 +94,15 @@ The hooks intentionally do not run tests, snapshots, documentation checks, build Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. -## CI gates +### CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. -## Daily commands +### Daily commands -Use these from the repo root: +The root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first. -```sh -pnpm run test # unit tests -pnpm run test:coverage # unit tests with per-file coverage gates -pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks -pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates -pnpm run lint # oxlint . -pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix -pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs -pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source -pnpm run verify-cordis-catalog # fail if either cordis catalog is stale -pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc -pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions -pnpm run verify-doc-graphs # fail if generated relationship docs are stale -pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown -pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax -pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type -pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling -pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits) -pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list -pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps -pnpm run verify-module-graph # fail if docs/module-graph.md is stale -pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files -pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable -pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check -``` - -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review. - -## Demos +### Demos The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: @@ -149,7 +122,7 @@ The ACP automation server exposes fresh agent sessions over JSON-RPC stdio and a pnpm run demo:acp ``` -## TODO markers +### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: @@ -159,7 +132,7 @@ Use one of three comment tags to flag known issues in the code, ordered by urgen Pick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe. -## Documenting types verbatim (`ts type-equiv`) +### Documenting types verbatim (`ts type-equiv`) The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: @@ -168,7 +141,3 @@ The [core data structures](core-data-structures/core.md) docs paste source-equiv ``` `pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. - -## Architecture context - -Read `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points. diff --git a/docs/development.zh.md b/docs/development.zh.md index 18e130e2f3..5582a85429 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,16 +2,18 @@ [English](development.md) | 中文 -本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。 +搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。 -## 前置条件 +## 搭建教程 + +### 前置条件 - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。 - 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 -## 首次搭建 +### 首次搭建 在仓库根目录安装依赖: @@ -19,7 +21,7 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 +安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: @@ -27,11 +29,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。 - -启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 - -检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。 +如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。 新克隆后请先运行一次类型检查: @@ -39,9 +37,13 @@ node scripts/install-lefthook.mjs pnpm run typecheck ``` -首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。 +`pnpm run typecheck` 成功退出即表示搭建完成。 -## TypeScript 项目布局 +## 贡献者参考 + +### TypeScript 项目布局 + +仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。 仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 @@ -68,7 +70,7 @@ pnpm run build `pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。 -## 环境变量 +### 环境变量 真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证: @@ -79,7 +81,7 @@ DEEPSEEK_BASE_URL=https://... # optional `DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 -## Git 钩子 +### Git 钩子 lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: @@ -92,44 +94,15 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 -## CI 门禁 +### CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 -## 日常命令 +### 日常命令 -在仓库根目录使用: +根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。 -```sh -pnpm run test # unit tests -pnpm run test:coverage # unit tests with per-file coverage gates -pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks -pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates -pnpm run lint # oxlint . -pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix -pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs -pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source -pnpm run verify-cordis-catalog # fail if either cordis catalog is stale -pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc -pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions -pnpm run verify-doc-graphs # fail if generated relationship docs are stale -pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown -pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax -pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type -pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling -pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits) -pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list -pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps -pnpm run verify-module-graph # fail if docs/module-graph.md is stale -pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files -pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable -pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check -``` - -修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。 - -## 演示 +### 演示 单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: @@ -149,7 +122,7 @@ ACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样 pnpm run demo:acp ``` -## TODO 标记 +### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: @@ -159,7 +132,7 @@ pnpm run demo:acp 请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。 -## 逐字记录类型(`ts type-equiv`) +### 逐字记录类型(`ts type-equiv`) [核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: @@ -168,7 +141,3 @@ pnpm run demo:acp ``` `pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 - -## 架构上下文 - -在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 33a4704ee5..2377ca0d69 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,23 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:235`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | @@ -31,13 +27,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:69`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -51,7 +47,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:124`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index b63e41b87b..5c6f7d4630 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/glossary.md glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 -glossary.zh.md: ed3009a054815f1c7165fc322e44cc9521527643 +glossary.zh.md: c3584731cc08b23cf7f47c09620a77b7bff65689 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index ed3009a054..c3584731cc 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -2,32 +2,32 @@ [English](glossary.md) | 中文 -DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包(package)的 README 与 Agent Note(agent 决策记录)中。 +DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包的 README 与 Agent Note 中。 FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 ## agent-scope -- **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*有范围的*(归属于恰好一个 [scope key](#scope-key))。只有两层,扁平结构:有范围的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 +- **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*带作用域的*(归属于恰好一个 [scope key](#scope-key))。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 - **scope key**:scope 的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身 scope 的 key。<a id="scope-key"></a> -- **agent 上下文(`agent.ctx`)**:agent 的有范围上下文;通过它进行的注册既是 scope 可见的,也是 scope 生命周期的(同一事实决定两者),其上的监听器参与该 agent 的 scope 过滤分发。注册表主体事件可以在各自的事件契约下保持故意不过滤。 +- **agent 上下文(`agent.ctx`)**:agent 的带作用域上下文;通过它进行的注册既具有 scope 可见性,其生命周期也绑定到该 scope(同一事实决定两者),其上的监听器参与该 agent 的 scope 过滤分发。注册表主体事件可以根据各自的事件契约有意保持不过滤。 - **scope carrier**:scope 过滤分发所携带的 `thisArg`(由 `scopeTarget` 构建);其过滤器放行无标签监听器加上主体自身的监听器。*无主体*的 carrier(没有 key)只放行无标签监听器。 -- **scoped dispatch**:规则是:关于某个 agent 活动的事件以该 agent 的 carrier 进行分发。关于注册表本身的事件(如「一个工具被添加了」)属于*注册表主体*事件,保持不过滤。 -- **shadowing**:最具体者胜出的名称解析:一个有范围的工具/片段/变量仅在该 scope 内替换同名的全局对应项。这是按 agent 定制 persona 和按 agent 定制工具变体的机制。 +- **scoped dispatch**:规则是:关于某个 agent 的活动的事件以该 agent 的 carrier 进行分发。关于注册表本身的事件(如「一个工具被添加了」)属于*注册表主体*事件,保持不过滤。 +- **shadowing**:最具体者胜出的名称解析:一个带作用域的工具/片段/变量仅在该 scope 内替换同名的全局对应项。这是按 agent 定制 persona 和按 agent 定制工具变体的机制。 - **restriction / scope-local 注册**:restriction(`tools.restrict`)为单个 scope 过滤全局工具表面(多个 restriction 取交集组合);scope-local 注册在过滤之后合并。被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分。 -- **setup window**:创建者组装 agent 有范围世界的创建时隙(`CreateAgentOptions.setup`):在 scope 和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次提示词尚未组装之前。setup 只做注册,从不驱动 agent。 +- **setup window**:创建者组装 agent 作用域环境的创建时隙(`CreateAgentOptions.setup`):在 scope 和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次提示词尚未组装之前。setup 只做注册,从不驱动 agent。 - **lineage**:以数据形式携带的父子关系事实(`parentSession`、持久的 `delegationDepth`、运行时 `subagentDepth`);从不影响可见性。<a id="lineage"></a> ## 目标 - **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和 Goal Round 上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 -- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中可包含零个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> -- **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 +- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个由目标触发的[轮次](#turn),其中可包含零个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> +- **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此在恢复或 fork 后,只有人类随后通过 `/goal` 或模型工具授权一次恢复操作,自动工作才能开始。 ## 人类命令 - **人类命令**:以斜杠开头的指令,由面向人类的适配器通过 `ctx.commands` 解释并执行,不会成为模型消息。它既不同于面向模型的工具,也不同于通过 `ctx.bash` 执行 shell 命令。 -- **命令平面**:由 UI 适配器与命令插件拥有的发现、解析、分发、取消和结果渲染。除非处理器另行改变持久领域,否则命令输出属于 UI 状态。 +- **命令平面**:由 UI 适配器和命令插件负责的发现、解析、分发、取消与结果渲染机制。除非处理器另行改变持久领域,否则命令输出属于 UI 状态。 - **目标命令**:`/goal` 是由 `dsh-command-goal` 提供的人类命令;它直接观察或更改当前目标,而目标领域拥有每条持久且模型可见的记录。 ## 循环层级 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 5ad0a7b7dd..464a3c8df0 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: d1fff61a1d3db7fbe93d3c95b144598c2ca41c8b -README.zh.md: 9f1d61f1b854ead0ef5851157cce2e9a6de83d89 +README.md: 2bac578034441bbe786ce54c051cc622bf9275c0 +README.zh.md: b7566bf6f88e03eb06227b63011f78252b4dcd07 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index d1fff61a1d..2bac578034 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -43,7 +43,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): -- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration. - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 9f1d61f1b8..b7566bf6f8 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -43,7 +43,7 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。 - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 0a048ef71a..299f7db46f 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -33,7 +33,7 @@ | English | 中文 | 首次出现 | 不要译作 | 备注 | |---|---|---|---|---| | agent | agent | agent(智能体) | | | -| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 | +| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 | | agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 | | agent loop | agent loop | agent loop(智能体循环) | | | | blob hash | blob hash | | | `git hash-object` 的结果 | @@ -46,6 +46,7 @@ | Function Calling | Function Calling | Function Calling(函数调用) | | | | harness | harness | | | | | harness engineering | harness engineering | | | | +| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 | | lint | lint | | | | | mock | mock | | | 保留英文;指测试替身 | | loader | loader | | | | @@ -54,13 +55,14 @@ | Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | | schema | schema | | | | | schema DSL | schema DSL | | | | -| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | +| seam | seam | | 接缝 | 本仓库的命名架构概念,正文保留英文;与 `extension point` 是不同概念 | | skill | skill | skill(技能) | | | +| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 | +| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` | | spawn | spawn | | | | | steering | steering | steering(中途引导) | | | | task id | task id | | 任务 id | 保留英文 | | subagent | subagent | | | | -| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | | waterfall | waterfall | waterfall(瀑布式事件) | | | | wheel | wheel 包 | | | Python 打包格式 | @@ -81,6 +83,7 @@ | build target | 构建目标 | | | | | cancel | 取消 | | | | | canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` | +| capability | 能力 | | | 必须与 `feature` → `功能` 区分 | | capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 | | feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 | | feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 | @@ -101,13 +104,11 @@ | contract | 契约 | | | 如:`pairing contract` →`配对契约` | | Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` | | Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 | -| coverage | 覆盖率 | | | | | crash recovery | 崩溃恢复 | | | | | deploy root | 部署根目录 | | | | | dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 | | durability | 持久性 | | | | | feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 | -| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 | | event | 事件 | | | | | event log | 事件日志 | | | | | event stream | 事件流 | | | | @@ -132,7 +133,6 @@ | integration | 集成 | | | | | interface | 接口 | | | | | language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 | -| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` | | merge | 合并 | | | | | message | 消息 | | | | | mod | 模组 | | | | @@ -143,7 +143,7 @@ | opt-out ratio | opt-out 比例 | | 退出检查比例 | | | orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | -| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 | +| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 | | pairing | 配对 | | | | | parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 | | peer dependency | 对等依赖 | 对等依赖(peer dependency) | | | @@ -170,16 +170,15 @@ | session | 会话 | | | | | session event | 会话事件 | | | | | setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 | +| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 | | sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 | | smoke test | 冒烟测试 | | | | | snapshot | 快照 | | | | | source of truth | 真源 | | 事实来源、唯一来源 | | | spine | 主干 | | | | -| staged | 暂存 | | | 沿用 git 官方中文翻译 | | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | | step | 步骤 | | | | | stream | 流 | | | | -| streaming | 流式输出 | | | | | structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) | | Summary | 概述 | | | 事故复盘标题用语 | | system prompt | 系统提示词 | | | | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d0e17c87a9..0f8b836da4 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -1,6 +1,6 @@ # Translation prompt (pipeline asset) -本文件是自动翻译流水线的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写,是流水线行为的拍板基线。渲染时把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`;除此之外不注入任何其他仓库文件(translation-rules.md 约束人和 agent 的翻译工作,不注入本模板)。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题,两者冲突时以文体样例为准。[提示词 v4 契约 Agent Note](../../.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md) 记录该协议的决策与取舍;修改本文件会改变翻译行为,需正常经过 PR 评审。 +本文件是自动翻译流水线的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写,是流水线行为的拍板基线。渲染时把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`;除此之外不注入任何其他仓库文件(translation-rules.md 约束人和 agent 的翻译工作,不注入本模板)。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题,两者冲突时以文体样例为准。[提示词 v4 契约 Agent Note](../../.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md) 记录兼容协议;v7 保留该协议并选择性吸收经评估的生成质量改进。修改本文件会改变翻译行为,需正常经过 PR 评审。 ## 占位符契约 @@ -33,128 +33,174 @@ ````text # Translation Prompt -You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose. +You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose. + +Read each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose. + +## Priority + +Apply these authorities in order: + +1. Preserve the source meaning and the required document structure, protected content, and formatting. +2. Follow the injected terminology table exactly. +3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing. +4. Apply the general writing guidance and illustrative examples in this prompt. + +A lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table. ## Quality Requirements ### Structure and Format Preservation -- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks. -- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions. -- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. -- Every relative link must point to the same target as in the source. Link text is translated; link targets are not. -- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. -- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width). +- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks. +- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units. +- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph. +- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions. +- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them. +- Every relative link must point to the same target as in the source. Translate link text; do not change link targets. +- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`. +- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers. + +### Faithfulness +- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides. +- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts. +- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged. +- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning. ### Tone and Style -- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. +- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it. - Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. -- Use polite imperative forms where the text instructs the reader to do something. +- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction. +- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them. +- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`. - Keep the author's register: concise stays concise, detailed stays detailed. ### Sentence Structure -- Break long sentences with commas or semicolons. Avoid run-on sentences. -- Prefer active voice. Convert passive constructions to active if it reads more naturally. -- Translate meaning, not words. Restructure sentences where the target language grammar requires it. -- Do not invent words or expressions that do not exist in natural technical writing of the target language. +- Break long sentences where the target language needs a pause. Avoid run-on sentences. +- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted. +- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers. +- Split or combine clauses when needed for readability, provided every source relationship remains explicit. +- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use. ### Word Choice - Prefer precise, formal vocabulary over casual or colloquial alternatives. - When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language. +- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language. +- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain. - Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience. -- Do not use the same word to translate two different source-language terms that carry distinct meanings. -- Avoid repeating the same verb in close proximity; vary word choice for readability. +- Do not use the same word to translate distinct source-language concepts when their distinction matters. +- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety. #### When translating into Chinese -- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: "three-package seam" → "由三个包构成的 seam", not "三包 seam". +- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: "three-package seam" → "由三个包构成的 seam", not "三包 seam". Do not add classifiers to code, identifiers, versions, units, or fixed names. ### Punctuation #### When translating into Chinese -- Use full-width Chinese punctuation in prose: `,。:;?!()「」`. -- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all. -- Use enumeration commas (、) between parallel items, not regular commas. -- List item endings: use semicolons or no punctuation. Do not end list items with commas. -- Put one half-width space between Chinese text and Latin words/numbers. -- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**). +- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text. +- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation. +- Use enumeration commas (、) between parallel Chinese items, not regular commas. +- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas. +- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters. +- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space. +- Use half-width digits and Latin letters, never full-width forms. +- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**). #### When translating into English -(To be added.) +- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text. +- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes. +- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor. +- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally. +- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose. ## Terminology A terminology table is provided below. Follow it strictly: - Render every listed term exactly as specified. -- When the target language is Chinese, use the "中文" column. On first occurrence, write the "首次出现" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses. +- When the target language is Chinese, use the "中文" column. On the document's first prose occurrence, write the "首次出现" value when one is specified; on later occurrences, write only the part before the parenthetical gloss. - When the target language is English, use the "English" column without a Chinese gloss; do not copy the "中文" or "首次出现" value into English prose. - If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later. - NEVER use translations listed in the "不要译作" column. -- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression. +- Code spans and other protected tokens remain verbatim even when their text resembles a listed term. +- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression. {{terminology}} ## Output Format -Produce your output in three XML sections: +Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence. The outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping. ```xml <translation> -(Complete translation of the source document) +(First pass: the complete translation, written as natural target-language technical prose) </translation> <review> -(Self-review notes, one correction per line with category tag, e.g.) +(Second pass: actual corrections only, one correction per line with a category tag, e.g.) - [Tone] "旁挂记录" → "伴随记录"(生造词) - [Sentence] 第 3 段补充逗号断句 - [Punctuation] 两处破折号替换为冒号 +- [Terminology: pending] source term → tentative rendering - 无修正 </review> <final> -(Final translation after corrections) +(Complete final translation after corrections) </final> ``` ## Self-Review Instructions -After writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category: +After writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. **Structure** -- Is the heading hierarchy, list shape, and code block content identical to the source? -- Are ALL comments inside code blocks left untranslated (byte-identical to source)? -- Is the language switcher line correctly flipped (not copied from source)? -- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs? +- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source? +- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source? +- Are inline code spans and machine-readable tokens verbatim? +- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one? +- Are link targets and emphasis spans preserved? +- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose? - Are wrapper-tag lines inside section bodies escaped with one additional backslash? +**Faithfulness** +- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides? +- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly? + **Tone & Style** -- Does every sentence read as if originally written by a native speaker? -- Is there any colloquial, casual, or overly informal phrasing? +- Does every sentence read as if originally written by a native technical author? +- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing? +- Are actors explicit where the target language needs them, without inventing responsibility? **Sentence Structure** - Are there run-on sentences that need breaking? -- Are there stiff passive constructions that should be converted to active voice? +- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor? +- Are conditions, concessions, negation, coordination, and modifiers scoped clearly? **Word Choice** - Are there overly literal translations that sound unnatural? -- Is the same target-language word used to translate two distinct source concepts? +- Are ordinary prose words left untranslated despite an established target-language expression? +- Does each polysemous word fit its local context? +- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition? - Is any slang or internal jargon present? **Terminology** -- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent? +- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent? - Are any "不要译作" forbidden translations present? -- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss? +- Do protected tokens remain untouched even when they resemble terminology entries? +- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice? **Punctuation** (when target is Chinese) -- Are there em-dashes that should be replaced with colons, periods, or commas? -- Are list items ending with commas instead of semicolons? -- Do RFC 2119 keywords preserve the source emphasis exactly? +- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms? +- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact? +- Are list-item endings grammatically consistent, with none ending in commas? +- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly? -Record corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write "无修正" in `<review>` and copy the translation unchanged into `<final>`. +Record actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`. ## Examples -Below are representative examples of common problems and their corrections. Follow the "Good" versions. +Below are representative examples of common problems and their corrections. Follow the "Good" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements. ### Colloquial verb → Professional verb - Source: `The repo pins pnpm@11.7.0 in package.json` diff --git a/docs/module-graph.md b/docs/module-graph.md index b2a70d7ab7..0e2e7e0c37 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,9 +38,12 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] + pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] + pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -410,7 +413,6 @@ flowchart TD pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings - pkg_agent --> pkg_brand pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -426,9 +428,6 @@ flowchart TD pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -488,6 +487,7 @@ flowchart TD pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout + pkg_token_meter --> pkg_compact pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session @@ -503,6 +503,10 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -620,13 +624,11 @@ flowchart TD pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -690,18 +692,11 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_paths - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_session_persistence - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval + pkg_bash_env --> pkg_bash + pkg_bash_env --> pkg_invariants + pkg_bash_env --> pkg_paths + pkg_bash_env --> pkg_session_persistence + pkg_bash_env --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -729,6 +724,13 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -885,6 +887,25 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_bash_env + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_bash_env + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tasks + pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -964,27 +985,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1020,6 +1020,39 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1040,17 +1073,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1115,11 +1137,10 @@ flowchart TD | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1136,9 +1157,10 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1168,7 +1190,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | @@ -1179,11 +1201,12 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1210,6 +1233,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1221,14 +1246,14 @@ flowchart TD | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b3ac5aae70..f033fde18f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -24,13 +24,12 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'steering/message' /** * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -50,7 +49,7 @@ export type SurfaceOp = * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -78,10 +77,31 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) ## Events +### `agent/*` + +#### `agent/inbox/spliced` — log-only + +```ts persistence-catalog +/** + * One normalized mutation of an agent's durable pending-message lists. + * Live dispatch precedes projection mutation, so synchronous observers may + * read the pre-splice inbox to recover the removed messages. + */ +'agent/inbox/spliced': { + target: InboxTarget + start: number + removedCount?: number + inserted: UserMessage[] + outcome?: 'canceled' +} +``` + +Source: [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -129,8 +149,8 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the cache-safe runtime-context snapshot). The LAST such - * event is the session's override ({@link effectiveApprovalPolicy}). + * from the runtime-context snapshot and live switch notices). The LAST + * such event is the session's override ({@link effectiveApprovalPolicy}). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -154,7 +174,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -170,7 +190,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `command/*` @@ -216,7 +236,31 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in 'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:51`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts) + +#### `compact/prune` — log-only + +```ts persistence-catalog +/** + * Shadow price of one model-free prune replacement — log-only, no + * surfaceOp. The shared shadow-price protocol: a surface `replace` event + * is priced by the metering event immediately before it (`compact/summary` + * for a summarizing compaction, this event for a prune), which states the + * heuristic token price of the exact replaced range so a pure consumer + * can subtract it without retaining per-node prices. The replacement MUST + * be appended synchronously right after this event. + */ +'compact/prune': { + /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ + shadowedTokenCount: number +} +``` + +Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -237,8 +281,11 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement - * is performed by a subsequent `user/message` event that shadows the - * compacted range. + * is performed by the immediately following `user/message` event that + * shadows the compacted range. That adjacency is contractual — the + * shadowed pricing fields are the replacement's shadow price, so a + * consumer may pair a replacement with the metering event directly + * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { summary: ContentBlock[] @@ -265,7 +312,20 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:26`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) + +### `goal/*` + +#### `goal/change` — log-only + +```ts persistence-catalog +/** + * Complete post-mutation goal state or clear tombstone. + */ +'goal/change': GoalChangeMeta +``` + +Source: [`packages/goal/goal/src/domain.ts:81`](../packages/goal/goal/src/domain.ts) ### `hook/*` @@ -318,7 +378,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `llm/retry` — log-only ```ts persistence-catalog -/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ +/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ 'llm/retry': { turn: number step: number @@ -341,7 +401,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- } ``` -Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` @@ -372,7 +432,7 @@ Source: [`packages/ui/permission/src/index.ts:50`](../packages/ui/permission/src 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -380,18 +440,13 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s ```ts persistence-catalog /** - * Registration-bound context metadata for the route a request resolved to, - * appended inside its step beside `request/header` and only when the route - * or capacity differs from the last record. It is log-only and deliberately - * NOT part of {@link EpochHeader}: capacity is adapter metadata about a - * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. `contextWindow` is absent - * when the route's adapter advertises no capacity. + * Route metadata for the next request, logged only when the route or capacity + * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -403,7 +458,7 @@ Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -434,9 +489,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle - * produced none of them. An explicitly supplied empty seed puts the marker - * at seq 0, distinguishing an empty resumed session from a fresh session. - * This log-only event is the durable projection of + * produced none of them. This log-only event is the durable projection of * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` * carry the meaning. * @@ -458,7 +511,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record<string, never> ``` -Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -485,17 +538,6 @@ Types: [SessionTitleLlmRequestEventData](core-data-structures/session-title.md) Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages/session-title/session-title-llm/src/index.ts) -### `steering/*` - -#### `steering/message` — surface - -```ts persistence-catalog -/** Steering content injected between steps of a running turn. */ -'steering/message': { turn: number; message: UserMessage } -``` - -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) - ### `step/*` #### `step/end` — log-only @@ -505,7 +547,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -514,7 +556,7 @@ Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -544,7 +586,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `tool/*` @@ -561,7 +603,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -634,7 +676,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) ### `turn/*` @@ -642,30 +684,33 @@ Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/ ```ts persistence-catalog /** - * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * awaits `session/flush` after an ordinary turn ends before claiming the next - * queued item. Success commits the turn; rejection is reported live and does - * not prevent later work. + * Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn + * with no entered step has no `step/start` or `step/end`. The loop does not await a + * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the + * per-request durability checkpoint, and consumers that read storage after + * `whenIdle()` flush themselves. Success commits the turn; rejection is + * reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } ``` Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) #### `turn/start` — log-only ```ts persistence-catalog /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn` before the loop claims queued input or runs pre-step. + * Rejection, empty input, cancellation, or failure may close it with no + * step; otherwise the following identified `user/message` event or batch + * records the messages entering the step. */ -'turn/start': { turn: number; trigger: TurnTrigger } +'turn/start': { turn: number } ``` -Types: [TurnTrigger](core-data-structures/session.md) - -Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) ### `user/*` @@ -676,14 +721,13 @@ Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/ * A user-role message on the model-visible surface: a direct human prompt * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron - * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * notifications, …), or an entered goal continuation round. All three + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index 42e2dfa56a..e538f244b9 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/postmortem/0001-acp-default-export-drops-inject.md 0001-acp-default-export-drops-inject.md: 2d36f24fa54814e39345d7fe68792023c2cf0194 -0001-acp-default-export-drops-inject.zh.md: c528f8be04013803274e80e51970754e92a935ae +0001-acp-default-export-drops-inject.zh.md: 6ae7d45f58e09f205a5653f7c6d306014d4d393e diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index c528f8be04..6ae7d45f58 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -6,7 +6,7 @@ Status: resolved (fix in PR(Pull Request) #41 `feat/acp-2-bridge`) ## 摘要 -两个集成错误在单元测试全覆盖的情况下仍然导致 ACP 崩溃:一个 default export 使 Loader 丢弃了 `inject`,一个经 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复方案增加了无需 API key 的真实 Loader 覆盖率,并为插件导出和可选服务访问制定了包(package)级规则。 +两个集成错误在单元测试全覆盖的情况下仍然导致 ACP 崩溃:一个默认导出使 Loader 丢弃了 `inject`,一个经可追踪代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复方案增加了无需 API key 的真实 Loader 覆盖率,并为插件导出和可选服务访问制定了包级规则。 ## 概述 @@ -20,7 +20,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 - bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 - 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 -- 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、*插件加载时*,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 +- 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 目录中的 `reflect.ts` 里对实际 fiber 遍历做了插桩,并运行了真实子进程。跟踪结果显示,异常在 `apply()` 第 179 行、*插件加载时*抛出,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 - 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 - 删除后暴露了 Bug #2:`session/load` 仍然在 `sessionPersistence` 上抛错——这是一个真正不同的机制(shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。 @@ -47,19 +47,19 @@ unwrapExports(exports: any) { } ``` -存在 default export 时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把整个命名空间丢弃了。Loader 随后基于空的 `inject` 构建了插件的 fiber。 +存在默认导出时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把整个命名空间丢弃了。Loader 随后基于空的 `inject` 构建了插件的 fiber。 因此 `apply` 在一个**没有注入任何服务**的 fiber 中运行。第一行 `const agents = ctx.agents` 遍历 fiber 树(ROOT → Include → Loader → ROOT),在所有 fiber 的 store 中都找不到 `agents`,到达根 fiber(`runtime === null`)后抛出 `cannot get property "agents" without inject`。崩溃发生在*加载时*,而非后续的请求处理器中——请求只是恰好触发了加载。 **修复:** 删除 `export default apply`。Loader 随后使用模块命名空间,正确识别 `inject`/`name`/`Config`,`apply` 在一个真正授予了声明服务的 fiber 中运行。 -## 根因 #2——可选服务读取通过 traceable shadow 触发 inject 守卫(导致 `session/load` 崩溃) +## 根因 #2——可选服务读取通过可追踪 shadow 触发 inject 守卫(导致 `session/load` 崩溃) -修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这个问题*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。 +修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这个问题*确实*源于 Cordis 的可追踪代理/shadow 机制,值得精确理解。 `session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意不包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,以机会性方式读取。 -Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: +Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从另一条 fiber 获取的*可追踪代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: ```ts ignore-check // reflect.ts get handler @@ -88,7 +88,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob ## 为什么所有测试都没有捕获(真正的失败) -两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来驱动它。** +两个 bug 都源于同一个根本流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来驱动它。** - 内存 harness 通过手动构建插件对象来挂载 bridge:`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获。 - 同一个 harness 将所有内容平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` 恢复要么运行在顶层(`!runtime` 绕过),要么通过一个 origin 仍然解析在 root 上的 shadow——掩盖了 Bug #2 的祖先遍历失败。 @@ -101,13 +101,13 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob - **删除 `export default apply`**(`packages/acp/acp/src/index.ts`)——Bug #1 的修复。 - **`AgentLoop.resume` 使用 `this.ctx.get('sessionPersistence')`**(`packages/core/agent-loop/src/index.ts`)——Bug #2 的修复,附注释说明 shadow 遍历陷阱。 -- **无需 key 的 `session/new` e2e,通过真实 stdio 运行**(`examples/acp-agent/tests/acp.e2e.ts`):以子进程方式通过真实 Loader 启动示例,并断言 `session/new` 正常返回。无需 API key 即可在 Bug #1 上大声失败。已验证恢复 `export default apply` 时测试失败。 +- **无需 key 的 `session/new` e2e,通过真实 stdio 运行**(`examples/acp-agent/tests/acp.e2e.ts`):以子进程方式通过真实 Loader 启动示例,并断言 `session/new` 正常返回。无需 API key 即可明确暴露 Bug #1。已验证恢复 `export default apply` 时测试失败。 - **e2e spawn 中设置 `TSX_TSCONFIG_PATH`**:子进程从临时 cwd 运行,tsx 无法通过向上搜索找到仓库根的 tsconfig `paths` 映射——因此 dsh-* 的 import 静默回退到已构建的 `lib/`。将 tsx 指向仓库 tsconfig 使解析不依赖 cwd,确保测试运行的是*源码*而非可能陈旧的构建产物。 - **[docs/testing.md](../testing.md) 规则**:「测试真实入口路径」,行覆盖率不等于行为覆盖率——将这一教训编纂为所有未来插件的规则。 ## 经验教训 - 命名空间插件与 default export 在 Cordis Loader 下互斥。选择命名空间形式(`name`/`inject`/`Config`/`apply`),不要添加 `export default`——`unwrapExports` 会丢弃命名空间。 -- 对于插件机会性读取但未在 `static inject` 中声明的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过仅向祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——非活跃后端读取为 `undefined`,而非在 teardown 过程中被交出)。 +- 对于插件机会性读取但未在 `static inject` 中声明的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过仅向祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认采用严格模式——非活跃后端读取为 `undefined`,不会在 teardown 期间仍将该后端返回给调用方)。 - 手动构建插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI,而非 key 门控之后。 -- 相信 trace,不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时看似合理但实际错误的推理之后,一个 fiber 遍历的 `console.error` 在几分钟内就找到了它。 +- 相信跟踪结果,不要迷信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时看似合理但实际错误的推理之后,一个 fiber 遍历的 `console.error` 在几分钟内就找到了它。 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 2aad7141c5..b10d882b59 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/postmortem/0002-js-expression-disabled-filesystem-tools.md 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: b103ec6de5d6d6406ba48ec34f6ebb479e472352 +0002-js-expression-disabled-filesystem-tools.zh.md: 3c18a48d3b7e925a6e75c2d3edb3ec642e72e1b3 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index b103ec6de5..3c18a48d3b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -12,13 +12,13 @@ ACP(Agent Client Protocol)示例试图通过 `disabled: !!js ...` 有条件 默认的 ACP 组合有意只启用 bash,因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍然需要 `read`、`write` 和 `edit`,因此这些插件被放在默认的 `cordis.yml` 中,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。 -Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行插值,但直接消费 `disabled` 等入口元数据。因此每个文件系统入口看到的都是一个 truthy 对象,在所有模式下均保持禁用。 +Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行插值,但直接读取 `disabled` 等配置项元数据。因此每个文件系统配置项看到的都是一个 truthy 对象,在所有模式下均保持禁用。 ## 影响 七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。其结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 渲染出通用的失败工具卡片。快照套件通过了,因为结构化会话日志和 stdout 渲染出的通用失败工具卡片均与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 -实际运行的受限默认模式并未获得意外的文件系统访问权限。一个简单的插值修复反而会制造该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 +实际运行的受限默认模式并未获得意外的文件系统访问权限。草率地直接修复插值反而会带来这一风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 ## 时间线 @@ -29,7 +29,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 根因 -实现时假设 `!!js` 适用于整个 Loader 入口。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 +实现时假设 `!!js` 适用于整个 Loader 配置项。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 @@ -37,8 +37,8 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader - 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的回放配置和独立的 request-header 类。 - [`AGENTS.md`](../../AGENTS.md) 与 [Cordis 入门](../cordis-primer.md#loader-configuration)明确说明 `!!js` 仅在插件 `config` 内有效,条件式组合应使用 overlay。 -- `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据中的表达式节点(包括 include patch 和插入的入口)。 -- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其被提交为预期输出。 +- `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 配置项元数据中的表达式节点(包括 include patch 和插入的配置项)。 +- `dsh-acp-snapshot` 在全新运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其被提交为预期输出。 ## 教训 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml new file mode 100644 index 0000000000..47a2e3fdd7 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md +0004-landlock-partial-notice-misclassified-child-failures.md: db810fdc896f9734d1b581617838d72166f4efc9 +0004-landlock-partial-notice-misclassified-child-failures.zh.md: 4a31fb038c44b036e6a183040947295a47221967 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md new file mode 100644 index 0000000000..db810fdc89 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md @@ -0,0 +1,55 @@ +# Post-mortem 0004: Landlock partial-enforcement notice misclassified child failures + +English | [中文](0004-landlock-partial-notice-misclassified-child-failures.zh.md) + +Status: resolved + +## Executive summary + +On kernels with an older Landlock ABI, the launcher prints a benign partial-enforcement notice before executing every child. The harness treated that shared `landlock-run:` prefix plus any nonzero child exit as launcher failure, so ordinary outcomes such as ripgrep's exit 1 for no matches surfaced as `SANDBOX_UNAVAILABLE`; the then-bash-backed filesystem search also hid that structured error behind `SEARCH_FAILED`. Broad signature rules and missing partial-ABI composition coverage let the defect through. Runner classification now requires status-gated fatal evidence after exact informational exclusions, and an assembled keyless scenario pins the surviving bash path. Filesystem search uses packaged ripgrep through the subprocess seam and does not cross sandboxed bash. + +## Summary + +The native launcher contract distinguishes two kinds of stderr lines. A partially enforcing kernel prints exactly `landlock-run: partial enforcement (older Landlock ABI)` and continues into the child. A launcher failure prints another `landlock-run:` line and exits 125 without executing the child. + +The harness represented both with one case-insensitive `landlock-run: ` substring. Its consumer classified any nonzero exit carrying that substring as runner failure. The child's status was therefore attached to the launcher's informational line: `false`, ripgrep's no-match exit 1, invalid-pattern exit 2, and even a child-selected exit 125 could be blamed on the sandbox despite successful confinement and execution. + +At the time of the incident, filesystem search added a second attribution error. Its bash-backed `runRipgrep()` caught every rejected bash run that was not aborted and replaced it with a generic cwd/shell-start `SEARCH_FAILED`, including the structured `SandboxUnavailableError` produced by the sandbox executor. + +## Impact + +On partial-ABI Landlock hosts, legitimate nonzero child outcomes could appear as sandbox infrastructure failure. `glob` and `grep` were especially visible because ripgrep uses exit 1 as successful empty search. When a real sandbox failure did occur through filesystem search, callers lost its `SANDBOX_UNAVAILABLE` code and received an incorrect startup diagnosis. + +The defect did not weaken confinement or run a command unconfined. Its security effect was availability and diagnostic integrity: a valid confined result was rejected or mislabeled. + +## Timeline + +- The native launcher contract defined exit 125 for launcher failures, a fatal `landlock-run:` line for every such failure, and the exact partial-enforcement notice for successful child execution. +- The sandbox provider reduced that contract to `runnerFailureSignatures: ['landlock-run: ']`; the bash consumer combined the prefix with any nonzero exit and reported stderr's first line. +- Unit tests covered clean success, denial diagnostics, and fatal runner prefixes. Real-runner tests self-skipped without a usable kernel and did not force partial enforcement followed by a nonzero child. +- A minimal POSIX wrapper that prints the notice and `exec`s its payload reproduced the failure with `false` and ripgrep no-match. +- Structured rules plus shared foreground/background classification and assembled replay coverage closed the surviving sandbox attribution gap. Filesystem search uses packaged ripgrep through `ctx.subprocess`; the fix leaves that path outside sandboxed bash. + +## Root cause + +The public sandbox result type could express only a bag of substrings. It could not state that Landlock failure requires exit 125, that evidence must occur within one fatal line, or that one exact line under the same prefix is informational. The boolean consumer consequently joined unrelated facts from different processes and selected the first stderr line for detail even when a later line was the fatal evidence. + +The test matrix mirrored that representation. Fake providers emitted either no runner line or an unambiguously fatal prefix; they never emitted a benign runner line before a child-controlled nonzero exit. Real Landlock coverage depended on the host ABI, so full-ABI hosts could not exercise the notice. In the incident-era search implementation, filesystem-search tests modeled raw spawn errors but not a structured error thrown by the real sandboxed bash composition. + +Stderr remains an in-band attribution channel. A confined child can deliberately reproduce a runner's gated fatal line and exit status, causing an availability/diagnostic false attribution. The tighter conjunction prevents the accidental collision in this incident but does not authenticate the writer; an out-of-band status protocol remains separate hardening, not a sandbox-bypass fix. + +## Guardrails added + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) carries optional allowed exit codes, case-insensitive per-line fatal signatures, and case-insensitive exact informational-line exclusions. +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line while bwrap, Seatbelt, and custom runners remain signature-only. +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) directly spawns the provider argv, so a pre-start rejection uses the spawn-error channel instead of localized shell diagnostics. Settled foreground and background execution share one evidence-returning classifier; fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr. +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) uses packaged ripgrep through `ctx.subprocess` and remains outside the sandboxed bash seam. +- The native-boundary regression cases live in [`partial-landlock.spec.ts`](../../packages/bash/bash-sandbox/tests/partial-landlock.spec.ts), including informational notices, fatal evidence, and foreground/background classification. +- The assembled product path is pinned by the [`partial-landlock` snapshot composition](../../examples/acp-agent/partial-landlock.cordis.snapshot.yml), independently of filesystem-search implementation choices. + +## Lessons + +- Process attribution requires a conjunction of independent evidence; a shared prefix is not a protocol. +- Informational and fatal diagnostics can share a namespace, so exclusions must be exact and narrow while unknown fatal lines stay fail-closed. +- An adapter must preserve structured failures owned by the seam below it instead of replacing them with its own nearest generic category. +- Platform-dependent behavior needs a deterministic fake at the native boundary plus one assembled product path; a self-skipping real-kernel test cannot carry that regression alone. diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md new file mode 100644 index 0000000000..4a31fb038c --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md @@ -0,0 +1,55 @@ +# 事故复盘(postmortem) 0004:Landlock 部分强制执行通知导致子进程失败被误归类 + +[English](0004-landlock-partial-notice-misclassified-child-failures.md) | 中文 + +Status: resolved + +## 摘要 + +在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;当时仍由 bash 支撑的文件系统搜索还会用 `SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据,并由一个组装后的无密钥场景固定仍然存在的 bash 路径。文件系统搜索通过 subprocess seam 运行打包的 ripgrep,不经过沙箱化 bash。 + +## 概述 + +原生 launcher 契约区分两类 stderr 行。内核只能部分强制执行时,会精确打印 `landlock-run: partial enforcement (older Landlock ABI)`,然后继续执行子进程。launcher 失败则打印另一行 `landlock-run:` 诊断,在不执行子进程的情况下以 125 退出。 + +harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情况。消费方只要发现非零退出同时携带该子串,就会归类为 runner 失败。因此,子进程的退出状态被错误地关联到 launcher 的信息性行:`false`、ripgrep 无匹配时的退出码 1、无效 pattern 的退出码 2,乃至由子进程自行选择的退出码 125,都可能在约束与执行均成功的情况下被错误归因为沙箱故障。 + +事故发生时,文件系统搜索又造成第二处归因错误。当时由 bash 支撑的 `runRipgrep()` 会捕获 bash 执行器除中止外抛出的所有错误,并将其替换为关于 cwd 或 shell 启动的通用 `SEARCH_FAILED`,其中也包括沙箱执行器产生的结构化 `SandboxUnavailableError`。 + +## 影响 + +在 Landlock ABI 只能部分强制执行的主机上,合法的非零子进程结果可能表现为沙箱基础设施故障。`glob` 和 `grep` 尤其容易暴露该问题,因为 ripgrep 把退出码 1 用作成功的空搜索。当文件系统搜索中确实发生沙箱故障时,调用方也会丢失其 `SANDBOX_UNAVAILABLE` 错误码,转而收到错误的启动诊断。 + +该缺陷没有削弱约束,也没有让命令在无约束状态下运行。其安全影响在于可用性与诊断完整性:有效的受限结果会被拒绝或错误标记。 + +## 时间线 + +- 原生 launcher 契约规定:launcher 失败使用退出码 125,每次此类失败都会打印一行致命的 `landlock-run:` 诊断;成功执行子进程时则打印精确的部分强制执行通知。 +- 沙箱提供方把该契约简化为 `runnerFailureSignatures: ['landlock-run: ']`;bash 消费方将此前缀与任意非零退出组合,并报告 stderr 的第一行。 +- 单元测试覆盖了无诊断的成功、拒绝诊断和致命 runner 前缀。真实 runner 测试在没有可用内核时会自行跳过,也没有强制构造「部分强制执行通知后跟非零子进程退出」的情况。 +- 一个最小 POSIX 包装脚本会打印该通知并 `exec` 其负载;它通过 `false` 与 ripgrep 无匹配场景复现了故障。 +- 结构化规则、前台与后台共享的分类逻辑和组装后的回放覆盖共同弥补了仍然存在的沙箱归因缺口。文件系统搜索通过 `ctx.subprocess` 运行打包的 ripgrep;本修复让该路径继续位于沙箱化 bash 之外。 + +## 根因 + +公开的沙箱结果类型只能表达一组子字符串。它无法表示 Landlock 失败必须使用退出码 125、证据必须出现在一行致命诊断内,或同一前缀下有一行精确文本属于信息性通知。消费方的布尔判定逻辑因此把来自不同进程且互不相关的事实组合在一起;即便致命证据位于后续行,它仍选用 stderr 的第一行作为详细信息。 + +测试矩阵与这种表示方式一致。模拟提供方要么不输出 runner 行,要么输出含义明确的致命前缀,从不在由子进程控制的非零退出前输出无害 runner 行。真实 Landlock 覆盖依赖主机 ABI,因此使用完整 ABI 的主机无法覆盖该通知。在事故发生时的搜索实现中,文件系统搜索测试模拟了原始 spawn 错误,却没有覆盖真实沙箱化 bash 组合抛出的结构化错误。 + +stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的门控致命诊断行与退出状态,造成可用性或诊断误归因。更严格的多项证据合取可以避免本次事故中的意外冲突,但无法验证写入者身份;带外状态协议仍属于独立的加固工作,而非沙箱绕过修复。 + +## 已添加的防护措施 + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。 +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,而 bwrap、Seatbelt 和自定义 runner 仍仅依据签名。 +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 直接 spawn 提供方 argv,因此启动前遭拒时使用 spawn 错误通道,而非本地化的 shell 诊断。已结算的前台与后台执行共用一个返回证据的分类器;致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。 +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 通过 `ctx.subprocess` 运行打包的 ripgrep,并继续位于沙箱化 bash seam 之外。 +- 原生边界回归用例位于 [`partial-landlock.spec.ts`](../../packages/bash/bash-sandbox/tests/partial-landlock.spec.ts),包括信息性通知、致命证据和前台/后台分类。 +- 组装后的产品路径由 [`partial-landlock` 快照组合](../../examples/acp-agent/partial-landlock.cordis.snapshot.yml)固定,独立于文件系统搜索的实现选择。 + +## 教训 + +- 进程归因需要多项独立证据同时成立;共享前缀不是协议。 +- 信息性诊断与致命诊断可以共享同一命名空间,因此排除规则必须精确且范围狭窄,同时对未知的致命行保持失败关闭。 +- 适配器必须保留下层 seam 所拥有的结构化失败,而不能用自身最接近的通用类别将其替换。 +- 平台相关行为需要在原生边界放置确定性的模拟实现,并覆盖一条组装后的产品路径;会自行跳过的真实内核测试无法独自固定该回归。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e027911357..07b3a398dd 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/README.md -README.md: 4858f8841e92a895f2d1a840b59b42758e83d952 -README.zh.md: 127eb19422f1eb6738c1d246791064096f4382f9 +README.md: ffde0057304856b7c7718e3dd1f4743c48ee193f +README.zh.md: 58635af16212f990d0f4e2621f4bfed437631645 diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index 4858f8841e..ffde005730 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -15,3 +15,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | | [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock partial-enforcement notice misclassified child failures | diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 127eb19422..58635af162 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -2,16 +2,17 @@ [English](README.md) | 中文 -事故复盘记录的是:一个 bug 流入了不该流入的环节(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 +事故复盘记录的是:一个 bug 出现在了不该出现的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及为此新增了哪些具体防护措施,以确保同类 bug 下次出现时会明确报错。 +事故复盘不是 [Agent Note](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及为此新增了哪些具体防护措施,以确保同类 bug 下次出现时会明确报错。 -当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 +当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试、工具、约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 -每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、可长期沿用的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 +每篇事故复盘以一段**执行摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、可长期沿用的教训是什么——然后才是后续的详细「概述、时间线、根因、防护措施」各节。 | # | 标题 | |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | -| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI | +| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent(智能体)验证了替代服务器,而非承载其会话的 GUI | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock 部分强制执行通知导致子进程失败被误归类 | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index e91b6e3c34..2fcbea62e4 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 89d495e05c7521becea052ad67ac602ba28c22ef -testing.zh.md: 8e19734d09d5b0bdbeffe9426bed7c12f23fbc41 +testing.md: 514ca4e1df7505b02350470d5de4a5ee3647634b +testing.zh.md: 6d24d8e74d726d53fa3500f57e34238483c5481c diff --git a/docs/testing.md b/docs/testing.md index 89d495e05c..514ca4e1df 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). -- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. +- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 8e19734d09..6d24d8e74d 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -7,7 +7,7 @@ ## 层级 - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 -- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。浏览器渲染的 Web GUI 旅程使用 `apps/web/tests/snapshots/`。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f93aedd658..03f4ce3bca 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,14 +18,15 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | -| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | @@ -207,6 +208,48 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. +## `@deepseek-ai/dsh-tool-pwsh` + +### `pwsh` + +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell 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\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) + +The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. + ## `@deepseek-ai/dsh-tool-cordis` ### `cordis_inspect` diff --git a/docs/typert-catalog-integration-design.i18n.yaml b/docs/typert-catalog-integration-design.i18n.yaml deleted file mode 100644 index a7290fae68..0000000000 --- a/docs/typert-catalog-integration-design.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 docs/typert-catalog-integration-design.md -typert-catalog-integration-design.md: c7d601730655f61f3b875ad5ad6997d3c888bfea -typert-catalog-integration-design.zh.md: abaddfe1f740d4bd7cff5b2db8fd91e34626aab8 diff --git a/docs/typert-catalog-integration-design.md b/docs/typert-catalog-integration-design.md deleted file mode 100644 index c7d6017306..0000000000 --- a/docs/typert-catalog-integration-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# Typert Catalog Integration Design - -English | [中文](typert-catalog-integration-design.zh.md) - -## Current State and Problem - -Typert already provides separate host/client `FaceModel` instances, a `TypeGraph` with explicit cross-face references, and analysis support for services, events, `@typert object`, generics, inheritance, and External types. The TypeScript compiler API should only translate source code into this standard model; downstream consumers should not traverse the TypeScript AST again. - -The repository currently has two catalog pipelines that analyze TypeScript source directly: the static API catalog consumed by `tool-cordis`, and the generation and freshness gate for `docs/cordis-catalog/events.md` and `docs/cordis-catalog/services.md`. They analyze the same services, events, and related types, but maintain separate collection and rendering logic, so they cannot prove that the Typert model is sufficient to represent the existing domain semantics. - -The first phase makes both pipelines consume the Typert model while keeping the three committed artifacts character-for-character identical to their pre-migration versions: - -- `docs/cordis-catalog/events.md` -- `docs/cordis-catalog/services.md` -- `packages/cordis/tool-cordis/src/api-catalog.ts` - -This phase does not require product plugins to publish Typert subpaths, example applications to load Typert, or changes to the runtime dependencies of `tool-cordis`. - -## Options - -### Drive `tool-cordis` from the Runtime Registry - -Each plugin publishes and loads Typert artifacts, then `tool-cordis` reads the current runtime model from `ctx.typert`. This path reflects the set of plugins actually loaded, but it requires every product package represented in the catalog to add package exports, generated artifacts, registry contributions, and application assembly. That integration surface is much larger than the analysis capability being validated now. - -### Publish Typert Artifacts Repository-Wide, Then Aggregate Them Statically - -All product packages generate host/client JS and DTS during the normal build/typecheck process, then the catalog generator aggregates those artifacts. This path establishes the complete publication protocol up front, but it also changes many package manifests and the build topology at once, coupling catalog migration to repository-wide Typert publication. - -### Analyze at Build Time, Then Project the Catalog - -`WorkspaceAnalyzer` builds a `WorkspaceModel` and `TypeGraph` from the host TypeScript project. The repository-specific `CordisCatalogProjector` consumes only that model and generates the three texts. `tool-cordis` continues to import the committed static `api-catalog.ts`, so the runtime does not need the Typert service. - -This phase uses build-time projection. It directly verifies that the standard Typert model can replace the existing AST collector while leaving runtime publication and automatic loading to separate follow-up decisions. - -## Phase-One Architecture - -```text -tsconfig.host.json - │ - ▼ -WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 - │ - ▼ -WorkspaceModel + TypeGraph - │ - ▼ -CordisCatalogProjector ── 不依赖 TypeScript AST - ├── docs/cordis-catalog/events.md - ├── docs/cordis-catalog/services.md - └── packages/cordis/tool-cordis/src/api-catalog.ts -``` - -The objects have the following responsibilities: - -- `WorkspaceAnalyzer` analyzes packages, exports, services, events, type declarations, and reference relationships, and produces a compiler-independent model. -- `WorkspaceModel` and `TypeGraph` are the standard data structures shared by all generation and scanning analyses. They preserve developer-authored generics, inheritance, and type trees without retaining the TypeScript AST. -- The root entry point of `@deepseek-ai/dsh-typert-generator` exports `CordisCatalogProjector`, which performs model-driven selection, sorting, summary extraction, source location handling, JSDoc completeness checks, type-link closure, and rendering in three text formats. Its implementation remains in a dedicated Cordis catalog file, but it does not create another package subpath or embed a list of repository type names. -- `scripts/gen-cordis-catalog.ts` provides `LINK_MAP`, `FOUNDATION_TYPE_NAMES`, `TYPE_LINK_EXEMPTIONS`, and the inherited Cordis list, injects them explicitly into the projector through `CordisCatalogPolicy`, and owns the write/check CLI behavior. The vendor Cordis core pages continue to be generated by a separate pinned-source projector. -- `tool-cordis` imports only the static `api-catalog.ts` and does not depend on `typert-registry` or `typert-loader`. - -`CordisCatalogProjector` is a repository-specific downstream consumer and is not part of Typert's general-purpose model. When adding another category, first extend the standard model, then add the corresponding projector. The Typert analyzer must not absorb Cordis documentation formats or `tool-cordis` presentation logic. - -## Model Additions - -In addition to type structure, the catalog's character-for-character projection needs the declaration forms written by developers and exact source locations. The standard model therefore retains event/service locations, body-free text for events and members, parameter initializers, and the export status and canonical text of type declarations. `SourceDeclarationModel` also indexes top-level exported declarations for ambiguity checks and static type closure, without promoting them to domain graph roots. - -```ts -interface SourceLocation { - readonly file: string - readonly line: number - readonly column: number -} - -interface EventModel { - readonly location: SourceLocation - readonly text: string -} -``` - -Repository-wide analysis supports building bounded `ts.Program` instances in package batches, then merging them through source-location-stable graph ids into a face model equivalent to monolithic analysis. This capability changes only the memory boundary of the compiler program; it does not change package, declaration, or type graph semantics. - -All information required by the projector must come from `WorkspaceModel` or `TypeGraph`. If a fact required for character-for-character compatibility cannot be expressed by the model, extend the standard model; do not reintroduce `ts.Node`, `ts.Symbol`, or `ts.TypeChecker` in the projector or script. - -## Character-for-Character Migration Oracle - -Before migration, retain the three texts produced by the old generator against the same source state. After migration, run the new analyzer and projector and require the three outputs to be byte-for-byte identical. Newlines, spaces, ordering, JSDoc, source pointers, and generated headers are all part of the comparison. - -`pnpm run verify-cordis-catalog` retains its `--check` mode, which reads the three committed artifacts and compares them directly with the newly computed results. A missing file or any differing character makes the artifact stale, and the error points to the single `pnpm run gen-cordis-catalog` repair command. - -Tests pin both of the following layers: - -- Typert fixture snapshots pin the `WorkspaceModel`, `TypeGraph`, JS, DTS, and Zod outputs, proving the behavior of the standard model and general-purpose emitters. -- Cordis catalog tests or snapshots pin the projector's three complete texts, proving that the repository-specific product projection does not bypass the standard model and providing directly reviewable textual evidence. - -The three committed artifacts are the migration oracle between the old and new implementations and the continuing freshness oracle after migration. The old `gen-cordis-api` AST collector is removed. The scripts and commands with that name remain only as compatibility entry points for the unified projector because the generated file header itself contains the command; retaining the entry point preserves the character-for-character oracle without creating a second source of truth. - -## Exact Change List - -### Typert Generator - -- Add the locations, authored declaration text, parameter initializers, export status, and top-level source declaration index needed for character-for-character projection, with coverage in analyzer and model snapshots. -- Support bounded package-batch analysis and prove that direct and batched models are equivalent. -- Confirm that the catalog's required service declarations, public instance members, JSDoc, generics, inheritance, and referenced types are all available from the model. -- Keep the TypeScript compiler API encapsulated within the analyzer; the public model and projector inputs do not expose compiler objects. - -### Cordis Catalog Projector - -- Select the complete set of Cordis services and events from the host `WorkspaceModel`. -- Preserve the old generator's JSDoc rules: events must have `@mode` and payload `@param` tags; service methods must have a matching `@param` for every parameter; non-void returns must have `@returns`. -- Compute the type links used by signatures and the transitive public type closure required by `tool-cordis` from the type graph. -- Receive caller-maintained type classifications and the inherited surface through an explicit `CordisCatalogPolicy`; do not maintain the repository documentation taxonomy inside the generator package. -- Preserve the existing output rules for source pointers, signatures, summaries, ordering, declaration truncation, and the inherited context catalog. -- Project once and render the events Markdown, services Markdown, and TypeScript API catalog, preventing drift between documentation and tool data. - -### Commands and Consumers - -- `scripts/gen-cordis-catalog.ts` maintains repository policy data, assembles the analyzer and projector, and writes/checks all three artifacts together. Parsing, validation, and rendering logic lives in the generator's dedicated Cordis source file and is exported uniformly from the package root entry point. -- Narrow `scripts/gen-cordis-api.ts` to a logic-free compatibility entry point for the unified CLI; the root `gen-cordis-api` and `verify-cordis-api` aliases point to that entry point. -- Restore the static catalog default in `tool-cordis` and remove its dependencies on `ctx.typert`, `typert-registry`, and runtime package-model completeness. -- `gen-doc-graphs` obtains the projector's model-level result once and reuses its services and events; it must not continue to import the AST collector or analyze the repository again. - -### Narrow the Scope of Phase-One Changes - -- Remove the newly added `./typert` and `./client/typert` exports and `lib/typert.*` files from product plugin package.json files. -- Remove `typert-registry` and `typert-loader` assembly from examples. -- Normal build/typecheck does not run repository-wide `gen-typert` or require product-package Typert artifacts to exist before it runs on a clean tree. -- Retain `packages/typert/generator`, `packages/typert/registry`, and `packages/typert/loader`, along with their independent fixture, emitter, and runtime registration tests. - -## Future Extensions - -The runtime registry remains the receiving and query layer for generated JS/Zod, and the loader remains the automatic loading mechanism; neither supplies data to the first-phase static catalog. When product packages need runtime reflection, they can opt in by publishing `package/typert` and `package/client/typert`, which the loader then registers with `ctx.typert`. - -Future integration does not change the phase-one layering: only the analyzer handles TypeScript, the standard model serves both static generation and scan analysis, and the emitter produces runtime artifacts from that same model. Whether to extend publication to more packages, enable the loader by default, or extend the runtime registry's query capabilities are separate review decisions and remain decoupled from the Cordis catalog migration. diff --git a/docs/typert-catalog-integration-design.zh.md b/docs/typert-catalog-integration-design.zh.md deleted file mode 100644 index abaddfe1f7..0000000000 --- a/docs/typert-catalog-integration-design.zh.md +++ /dev/null @@ -1,133 +0,0 @@ -# Typert catalog 接入设计 - -[English](typert-catalog-integration-design.md) | 中文 - -## 现状与问题 - -Typert 已经具备独立的 host/client `FaceModel`、可显式跨 face 引用的 `TypeGraph`,以及 service、event、`@typert object`、泛型、继承和 External 类型的分析能力。TypeScript compiler API 只应负责把源码转换成这套标准模型;后续消费者不应再次遍历 TypeScript AST。 - -仓库目前有两条直接分析 TypeScript 源码的 catalog 链路:`tool-cordis` 使用的静态 API catalog,以及 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md` 的生成与 freshness gate。它们分析的是同一批 service、event 和相关类型,却分别维护收集与渲染逻辑,不能证明 Typert 模型足以承载现有业务语义。 - -第一阶段的目标是让这两条链路共同消费 Typert 模型,并保持三份已提交产物与迁移前字符级一致: - -- `docs/cordis-catalog/events.md` -- `docs/cordis-catalog/services.md` -- `packages/cordis/tool-cordis/src/api-catalog.ts` - -本阶段不要求业务插件发布 Typert 子路径,不要求示例应用加载 Typert,也不改变 `tool-cordis` 的运行时依赖关系。 - -## 可选路径 - -### 运行时 registry 驱动 `tool-cordis` - -每个插件发布并加载 Typert 产物,`tool-cordis` 再从 `ctx.typert` 读取当前运行时模型。这条路径可以反映实际加载的插件集合,但会要求所有参与 catalog 的业务包增加 package exports、生成产物、registry contribution 和应用装配,接入面远大于当前要验证的分析能力。 - -### 全仓发布 Typert 产物后静态汇总 - -所有业务包在普通 build/typecheck 中生成 host/client JS 与 DTS,再由 catalog 生成器汇总这些产物。这条路径能够提前建立完整的发布协议,但会同时修改大量 package manifest 和构建拓扑,使 catalog 迁移与 Typert 的全仓发布绑定。 - -### 构建期分析后投影 catalog - -`WorkspaceAnalyzer` 从 host TypeScript project 构建 `WorkspaceModel` 与 `TypeGraph`,仓库专用的 `CordisCatalogProjector` 只消费该模型并生成三份文本。`tool-cordis` 继续导入已提交的静态 `api-catalog.ts`,运行时不需要 Typert service。 - -本阶段采用构建期投影。它直接验证 Typert 标准模型能否替代现有 AST collector,同时把运行时 publication 和自动加载留在独立的后续决策中。 - -## 第一阶段架构 - -```text -tsconfig.host.json - │ - ▼ -WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 - │ - ▼ -WorkspaceModel + TypeGraph - │ - ▼ -CordisCatalogProjector ── 不依赖 TypeScript AST - ├── docs/cordis-catalog/events.md - ├── docs/cordis-catalog/services.md - └── packages/cordis/tool-cordis/src/api-catalog.ts -``` - -各对象的职责如下: - -- `WorkspaceAnalyzer` 负责 package、export、service、event、类型声明和引用关系的分析,并产生 compiler-independent model。 -- `WorkspaceModel` 与 `TypeGraph` 是所有生成和扫描分析共用的标准数据结构,保留开发者写出的泛型、继承和类型树,不保存 TypeScript AST。 -- `@deepseek-ai/dsh-typert-generator` 根入口导出的 `CordisCatalogProjector` 负责模型驱动的选择、排序、摘要、源位置、JSDoc 完整性、类型链接闭包和三种文本格式;实现仍单独放在 Cordis catalog 专用文件中,但不形成额外的 package subpath,也不内置仓库类型名单。 -- `scripts/gen-cordis-catalog.ts` 提供 `LINK_MAP`、`FOUNDATION_TYPE_NAMES`、`TYPE_LINK_EXEMPTIONS` 和 inherited Cordis 清单,通过 `CordisCatalogPolicy` 显式注入 projector,并负责 write/check 的命令行行为;vendor Cordis core 页面仍由独立的 pinned-source projector 生成。 -- `tool-cordis` 只导入静态 `api-catalog.ts`,不依赖 `typert-registry` 或 `typert-loader`。 - -`CordisCatalogProjector` 是仓库业务消费者,不进入 Typert 通用模型。新增其他类别时,先扩展标准模型,再增加对应 projector;Typert analyzer 不吸收 Cordis 文档格式或 `tool-cordis` 展示逻辑。 - -## 模型补充 - -Catalog 的字符级投影除了类型结构,还需要开发者写下的声明形式和精确源码位置。标准模型因此保留 event/service location、event/member 的 body-free text、parameter initializer,以及 type declaration 的 export 状态和 canonical text;`SourceDeclarationModel` 另外索引顶层导出声明,供歧义检查和静态类型闭包使用,但不把它们提升为业务 graph root。 - -```ts -interface SourceLocation { - readonly file: string - readonly line: number - readonly column: number -} - -interface EventModel { - readonly location: SourceLocation - readonly text: string -} -``` - -全仓分析支持按 package 分批构建有界 `ts.Program`,再依靠源码位置稳定的 graph id 合并为与一次性分析等价的 face model。该能力只改变 compiler program 的内存边界,不改变 package、declaration 或 type graph 语义。 - -projector 所需信息必须来自 `WorkspaceModel` 或 `TypeGraph`。如果字符级兼容需要的事实无法从模型表达,应补充标准模型;不得在 projector 或脚本中重新引入 `ts.Node`、`ts.Symbol` 或 `ts.TypeChecker`。 - -## 字符级迁移 oracle - -迁移前,在同一份源码状态下保留旧生成器产生的三份文本。迁移后运行新的 analyzer 与 projector,要求三份输出逐字节相等;换行、空格、排序、JSDoc、source pointer 和生成头都属于比较内容。 - -`pnpm run verify-cordis-catalog` 的 `--check` 模式继续读取三份 committed artifact,并与本次计算结果直接比较。任一文件缺失或任一字符不同都视为 stale,错误信息指向统一的 `pnpm run gen-cordis-catalog` 修复命令。 - -测试同时固定以下两层: - -- Typert fixture snapshots 固定 `WorkspaceModel`、`TypeGraph`、JS、DTS 与 Zod 输出,证明标准模型和通用 emitter 的行为。 -- Cordis catalog 测试或 snapshot 固定 projector 的三份完整文本,证明仓库业务投影没有绕过标准模型,并给出可直接评审的文本证据。 - -三份 committed artifact 是旧实现与新实现的迁移 oracle,也是迁移完成后的持续 freshness oracle。旧 `gen-cordis-api` AST collector 被删除;同名脚本和命令只作为统一 projector 的兼容入口保留,因为生成文件头本身包含该命令,保留入口可以维持字符级 oracle 而不产生第二套真源。 - -## 精确改造清单 - -### Typert generator - -- 补齐字符级投影所需的 location、authored declaration text、parameter initializer、export 状态和顶层 source declaration index,并在 analyzer 与 model snapshots 中覆盖。 -- 支持有界 package batch 分析,并证明 direct 与 batched model 等价。 -- 确认 catalog 所需的 service 声明、public instance member、JSDoc、泛型、继承和引用类型均可从 model 读取。 -- 保持 TypeScript compiler API 封装在 analyzer 内;公共 model 和 projector 输入不暴露 compiler 对象。 - -### Cordis catalog projector - -- 从 host `WorkspaceModel` 选择完整的 Cordis service/event 集合。 -- 保留旧生成器的 JSDoc 规则:event 必须有 `@mode` 和 payload `@param`,service method 必须有参数对应的 `@param`,非 void 返回必须有 `@returns`。 -- 从 type graph 计算签名涉及的类型链接和 `tool-cordis` 所需的传递 public type closure。 -- 通过显式 `CordisCatalogPolicy` 接收调用方维护的类型分类和 inherited surface,不在 generator 包内维护仓库文档 taxonomy。 -- 保留 source pointer、签名、摘要、排序、声明截断和 inherited context catalog 的既有输出规则。 -- 一次投影并渲染 events Markdown、services Markdown 与 TypeScript API catalog,避免文档和工具数据漂移。 - -### 命令与消费方 - -- `scripts/gen-cordis-catalog.ts` 维护仓库 policy 数据、组装 analyzer/projector,并同时 write/check 三份产物;解析、校验和渲染逻辑位于 generator 的 Cordis 专用源文件,并统一从 package 根入口导出。 -- 将 `scripts/gen-cordis-api.ts` 收窄为统一 CLI 的无逻辑兼容入口;根目录的 `gen-cordis-api`、`verify-cordis-api` aliases 指向该入口。 -- `tool-cordis` 恢复静态 catalog 默认值,移除对 `ctx.typert`、`typert-registry` 和运行时 package model 完整性的依赖。 -- `gen-doc-graphs` 一次取得 projector 的 model-level 结果并复用 services/events,不能继续导入 AST collector 或重复分析全仓。 - -### 收窄本阶段改动面 - -- 撤销业务插件 package.json 中新增的 `./typert`、`./client/typert` exports 和 `lib/typert.*` files。 -- 撤销 examples 中的 `typert-registry`、`typert-loader` 装配。 -- 普通 build/typecheck 不运行全仓 `gen-typert`,也不要求 clean tree 预先存在业务包 Typert artifact。 -- 保留 `packages/typert/generator`、`packages/typert/registry`、`packages/typert/loader` 及其独立 fixture、emitter 和 runtime registration 测试。 - -## 后续扩展 - -Runtime registry 继续作为生成 JS/Zod 后的接收与查询层,loader 继续作为自动装载机制;两者不承担第一阶段静态 catalog 的数据来源。业务包需要运行时反射时,可以按 package opt-in 发布 `package/typert` 与 `package/client/typert`,再由 loader 注册到 `ctx.typert`。 - -后续接入不改变本阶段的分层:TypeScript 只进入 analyzer,标准模型同时服务静态生成与扫描分析,runtime artifact 由 emitter 从同一模型产生。是否把更多 package 接入 publication、是否默认启用 loader,以及 runtime registry 最终提供哪些查询能力,分别评审,不与 Cordis catalog 迁移捆绑。 diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml index 7740eda954..7fca045189 100644 --- a/docs/user/develop/basic/config.i18n.yaml +++ b/docs/user/develop/basic/config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -config.md: 26d2d48ebede74194fbf306aa97d214bdb99b722 -config.zh.md: 9ed389b16779f25c633d0c8772f8658197ba4322 +# pnpm run verify-translation-pairing --write docs/user/develop/basic/config.md +config.md: 11a2311464789f74537cc7c4435f83ec07ca26fd +config.zh.md: 4e827ecafa6bfaf87c3e3f118425e656e1254787 diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md index 26d2d48ebe..11a2311464 100644 --- a/docs/user/develop/basic/config.md +++ b/docs/user/develop/basic/config.md @@ -31,13 +31,15 @@ export function apply(ctx: Context, config: Config) { } ``` -Configure it in `cordis.yml`: +Add the configuration to the inserted local plugin row in `scratch-plugin/cordis.yml`: ```yaml -- name: './src/my-plugin.ts' - config: - greeting: 'Hi there' - maxRetries: 5 +- insert: + - id: hello + name: './src/my-plugin.ts' + config: + greeting: 'Hi there' + maxRetries: 5 ``` When loading the plugin, Cordis uses the exported schema to validate configuration and fill defaults. Do not export a plain object as `Config`; it does not implement the Standard Schema interface required by Cordis. @@ -91,22 +93,7 @@ The test is whether `cordis.yml` can change the value without a code edit. ### Fail loudly on invalid configuration -If configuration refers to an unregistered LLM provider route or another nonexistent resource, fail early instead of silently skipping it: - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-llm' - -export interface ModelConfig { - provider: string -} - -export function apply(ctx: Context, config: ModelConfig) { - if (!ctx.llm.listProviders().some(provider => provider.id === config.provider)) { - throw new Error(`LLM provider "${config.provider}" is not registered`) - } -} -``` +Express self-contained constraints in the schema so invalid configuration fails while the plugin loads. References to services or registered resources require dependency injection; the [services tutorial](../framework/service.md) introduces that contract. ## Work with HMR diff --git a/docs/user/develop/basic/config.zh.md b/docs/user/develop/basic/config.zh.md index 9ed389b167..4e827ecafa 100644 --- a/docs/user/develop/basic/config.zh.md +++ b/docs/user/develop/basic/config.zh.md @@ -31,13 +31,15 @@ export function apply(ctx: Context, config: Config) { } ``` -用户在 `cordis.yml` 中这样使用: +在 `scratch-plugin/cordis.yml` 新插入的本地插件行中添加配置: ```yaml -- name: './src/my-plugin.ts' - config: - greeting: 'Hi there' - maxRetries: 5 +- insert: + - id: hello + name: './src/my-plugin.ts' + config: + greeting: 'Hi there' + maxRetries: 5 ``` 插件加载时,Cordis 会通过导出的 schema 校验配置,并填充未提供字段的默认值。不要导出普通对象作为 `Config`,因为它不满足 Cordis 要求的 Standard Schema 接口。 @@ -75,7 +77,7 @@ Schema 在插件加载时执行校验。如果配置不合法,插件会加载 ### 无硬编码可调参数 -Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。 +Harness 的约定:**凡是不同部署可能需要采用不同值的参数,都必须定义为配置字段**。 ```ts // Wrong: hardcoded timeout. @@ -91,26 +93,11 @@ export interface Config { ### 配置错误要响亮 -如果配置引用了未注册的 LLM 提供方路由或其他不存在的资源,应该尽早报错,而不是静默跳过: - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-llm' - -export interface ModelConfig { - provider: string -} - -export function apply(ctx: Context, config: ModelConfig) { - if (!ctx.llm.listProviders().some(provider => provider.id === config.provider)) { - throw new Error(`LLM provider "${config.provider}" is not registered`) - } -} -``` +在 schema 中表达自身完备的约束,使无效配置在插件加载时失败。对服务或已注册资源的引用需要依赖注入;[服务教程](../framework/service.md)会介绍这项契约。 ## 配合 HMR -配置变更会触发插件热替换:修改 `cordis.yml` 中某个插件的 `config`,框架会卸载旧实例、加载新实例。由于注册都是效果(自动清理),这个过程是安全的。 +配置变更会触发插件热替换:修改 `cordis.yml` 中某个插件的 `config` 后,框架会卸载旧实例并加载新实例。由于注册都属于 effect 并会自动清理,替换后不会保留旧实例的注册。 ## 下一步 diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index a6ab84c3e0..4298808fc7 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -index.md: 5a9f8dfb8f2d87dfbd2ba30b4d09d002ae9b635c -index.zh.md: 08aca87cbc02d1b0dfbe6fe2d92b3f6e87075097 +# pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md +index.md: 45c8dfe495cd99da46a7b259b407af8deff570b3 +index.zh.md: 9d8ee47e6f07fb4a897f49487cb7327904573c1b diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 5a9f8dfb8f..45c8dfe495 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -2,7 +2,15 @@ English | [中文](index.zh.md) -This guide creates a minimal Harness plugin and loads it into an agent. +This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [quick start](../../guide/quickstart.md). + +## Create a local project + +From the repository root, create a scratch project for the tutorial: + +```sh +mkdir -p scratch-plugin/src +``` ## What is a plugin? @@ -22,7 +30,7 @@ That is the complete shape. ## Create the plugin file -Create `src/my-plugin.ts` in your project: +Create `scratch-plugin/src/my-plugin.ts`: ```ts import type { Context } from 'cordis' @@ -37,14 +45,21 @@ export function apply(ctx: Context) { ## Register it in cordis.yml -Add an entry to `cordis.yml`: +Create `scratch-plugin/cordis.yml` as a Web overlay that inserts the local plugin: ```yaml -- id: hello - name: './src/my-plugin.ts' +- insert: + - id: hello + name: './src/my-plugin.ts' ``` -After startup, the console prints `[hello-plugin] plugin loaded!`. +Start the Web UI with that overlay: + +```sh +pnpm run dsh web --config ./scratch-plugin/cordis.yml +``` + +Open `http://127.0.0.1:3080`. The terminal prints `[hello-plugin] plugin loaded!` during startup. ## Automatic cleanup @@ -120,35 +135,6 @@ export default class MyService extends Service { Function form is sufficient in most cases. Use class form when the plugin provides a service to other plugins; see [services and dependencies](../framework/service.md). -## Complete example - -A minimal tool plugin registers its definition on `ctx.tools`: - -```ts -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const name = 'greet-tool' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'greet', - description: 'Greet the named person.', - parameters: { - name: { type: 'string', required: true }, - }, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - async execute(args) { - return `Hello, ${args.name}!` - }, - })) -} -``` - ## Next steps - [Build a tool](./tool.md) — learn the tool definition DSL diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 08aca87cbc..9d8ee47e6f 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -2,7 +2,15 @@ [English](index.md) | 中文 -本文带你编写一个最小的 Harness 插件并加载到 Agent 中。 +本教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请从已完成[快速开始](../../guide/quickstart.md)的仓库检出开始。 + +## 创建本地项目 + +在仓库根目录创建本教程使用的临时项目: + +```sh +mkdir -p scratch-plugin/src +``` ## 插件是什么 @@ -18,11 +26,11 @@ export function apply(ctx: Context) { } ``` -就这么简单。 +这就是完整结构。 ## 创建插件文件 -在你的项目目录下创建 `src/my-plugin.ts`: +创建 `scratch-plugin/src/my-plugin.ts`: ```ts import type { Context } from 'cordis' @@ -37,18 +45,25 @@ export function apply(ctx: Context) { ## 注册到 cordis.yml -在你的 `cordis.yml` 中添加一条: +创建 `scratch-plugin/cordis.yml`,作为插入本地插件的 Web 覆盖层: ```yaml -- id: hello - name: './src/my-plugin.ts' +- insert: + - id: hello + name: './src/my-plugin.ts' ``` -启动后你会在控制台看到 `[hello-plugin] plugin loaded!`。 +使用该覆盖层启动 Web UI: + +```sh +pnpm run dsh web --config ./scratch-plugin/cordis.yml +``` + +打开 `http://127.0.0.1:3080`。启动期间,终端会打印 `[hello-plugin] plugin loaded!`。 ## 自动清理 -通过 `ctx` 注册的任何东西——事件监听、tool、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListener 或 clearInterval。 +通过 `ctx` 注册的任何东西——事件监听、工具、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListener 或 clearInterval。 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: @@ -118,38 +133,9 @@ export default class MyService extends Service { } ``` -大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service.md))。 - -## 完整示例 - -最小化的工具插件会在 `ctx.tools` 上注册其定义: - -```ts -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const name = 'greet-tool' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'greet', - description: 'Greet the named person.', - parameters: { - name: { type: 'string', required: true }, - }, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - async execute(args) { - return `Hello, ${args.name}!` - }, - })) -} -``` +大多数情况下,函数形式足够了。当插件需要向其他插件提供服务时,可使用类形式(见 [服务与依赖](../framework/service.md))。 ## 下一步 -- [开发一个 Tool](./tool.md) — 详细了解 tool 定义 DSL +- [开发一个工具](./tool.md) — 详细了解工具定义 DSL - [插件配置](./config.md) — 让插件接受用户配置 diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 697c88e98f..0aa1bbb4cb 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -tool.md: 0d7cbc3f0b86f88fb67aeff6aa61181dff2912ee -tool.zh.md: 30cc871d7b417bdf7f33025b22e3f0965e2b8805 +# pnpm run verify-translation-pairing --write docs/user/develop/basic/tool.md +tool.md: 93a1a96feba814a564f8800c8e7b865fe9c0cb73 +tool.zh.md: 18e6b9b5d9c17b00c26aa7b98e6ac4531315dc5e diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 0d7cbc3f0b..93a1a96feb 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -2,15 +2,17 @@ English | [中文](tool.zh.md) -A tool is a capability the model can call. This guide builds one with `defineTool`. +This tutorial adds a `greet` tool to the Web UI. Complete [Your first plugin](./) first and keep its `scratch-plugin` directory. -## Minimal example +## Create the tool plugin + +Replace `scratch-plugin/src/my-plugin.ts` with: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'my-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { @@ -25,221 +27,26 @@ export function apply(ctx: Context) { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args) { - // args is inferred as { name: string }. return `Hello, ${args.name}!` }, })) } ``` -## Parameter definitions +`inject` makes Cordis wait for the tool registry. `defineTool` infers and validates `args` from `parameters`; `execute` returns the canonical value declared by `output.schema`, and `output.render` converts that value to model-facing content. -`parameters` uses a compact format that the framework converts to the JSON Schema sent to the model. +## Run and call the tool -### Primitive types +Restart the development command if it is not running: -```ts -export const parameters = { - path: { type: 'string', required: true }, - limit: { type: 'integer' }, - recursive: { type: 'boolean' }, - parent: { type: 'null' }, -} -// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null } +```sh +pnpm run dsh web --config ./scratch-plugin/cordis.yml ``` -### Enums - -```ts -export const parameters = { - mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, -} -// Inferred type: { mode: 'read' | 'write' | 'append' } -``` - -### Nested objects - -```ts -export const parameters = { - options: { - type: 'object', - additionalProperties: true, - properties: { - timeout: { type: 'number' }, - retries: { type: 'number' }, - }, - }, -} -// The declared fields are inferred; additional JSON-valued keys are allowed. -``` - -### Arrays - -```ts -export const parameters = { - tags: { - type: 'array', - items: { type: 'string' }, - }, -} -// Inferred type: { tags?: string[] } -``` - -### Property fields - -| Field | Type | Meaning | -|------|------|------| -| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | Value type; `json` accepts any lossless JSON value | -| `required` | `true` | Marks the property required and affects inference | -| `description` | `string` | Description sent to the model | -| `enum` / `const` | matching scalar values | Allowed literal values, checked at author and runtime boundaries | -| `properties` | `ParameterSchemaSpec` | Nested properties for an object | -| `additionalProperties` | `true \| false` | Required on every explicit object node | -| `items` | `ValueSchemaSpec` | Element schema for an array | -| `oneOf` | at least two `ValueSchemaSpec` branches | Requires exactly one matching branch; used instead of `type` | - -The outer `parameters` map is an implicit open object. Explicit nested objects choose their openness; raw JSON Schema registered without `defineTool` keeps JSON Schema's open-by-default behavior. - -## The execute function - -`execute` receives validated, inferred `args` and an `exec` execution context: - -```ts -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const tool = defineTool({ - name: 'example', - description: 'Return an example result.', - parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - async execute(args, exec) { - // args: inferred from parameters - // exec: ToolExecution context - - // Return the value declared by output.schema. - void args - void exec - return 'result here' - }, -}) -``` - -### Return value - -`execute` returns the lossless JSON value declared by `output.schema`. `output.render(args, value)` separately turns that validated value into the Native/model-facing content: - -```ts ignore-check -output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string', required: true }, - content: { type: 'string', required: true }, - }, - }, - render: (_args, value) => [{ type: 'text', text: value.content }], -}, -async execute(args) { - return { path: args.path, content: await readFile(args.path, 'utf8') } -} -``` - -The canonical value is available to execution-time programmatic callers and is not persisted in `tool/result`; the rendered content and optional `presentationMeta` are the replayable projections. A body value that does not satisfy the schema, or is not lossless JSON, becomes an `INVALID_TOOL_OUTPUT` failure. - -### Argument validation - -Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call. - -Do not repeat type validation inside `execute`. - -## Presentation - -A tool can define transport-neutral presentation methods for terminal and web clients: - -```ts ignore-check -defineTool({ - name: 'bash', - // ... - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - presentCall(args) { - return { - card: 'terminal', - title: args.command, - } - }, - presentResult(args, result) { - return { - card: 'terminal', - output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), - } - }, -}) -``` - -`presentCall` and `presentResult` are **pure functions**. Streaming UI and session replay may call them more than once. - -## Registration and unloading - -`ctx.tools.register()` returns a disposer, but a registration made through `ctx` is already tracked by the framework. Unloading the plugin removes the tool automatically, so the plugin does not call the disposer itself. - -```ts ignore-check -// This is sufficient: -ctx.tools.register(defineTool({ /* ... */ })) - -// No saved disposer or extra cleanup registration is needed. -``` - -## Complete example - -This tool counts files in a directory: - -```ts -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { readdir } from 'node:fs/promises' - -export const name = 'file-counter' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'count_files', - description: 'Count files in a directory.', - parameters: { - path: { type: 'string', required: true, description: 'Directory path' }, - extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, - }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - count: { type: 'integer', required: true }, - files: { type: 'array', required: true, items: { type: 'string' } }, - }, - }, - render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], - }, - async execute(args) { - const entries = await readdir(args.path, { withFileTypes: true }) - let files = entries.filter(e => e.isFile()) - if (args.extension) { - files = files.filter(f => f.name.endsWith(args.extension!)) - } - return { count: files.length, files: files.map(file => file.name) } - }, - })) -} -``` +Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The model can call `greet` and receives `Hello, Ada!` as the tool result. ## Next steps -- [Plugin configuration](./config.md) — make the tool configurable -- [Capability layering](../practice/) — understand the interface/implementation/consumer pattern +- [Plugin configuration](./config.md) — make the greeting configurable. +- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, Code Mode, and UI cards. +- [Capability layering](../practice/) — split a replaceable capability into interface, implementation, and consumer packages. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 30cc871d7b..18e6b9b5d9 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -1,16 +1,18 @@ -# 开发一个 Tool +# 构建工具 [English](tool.md) | 中文 -Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个 tool。 +本教程会在 Web UI 中添加一个 `greet` 工具。请先完成[第一个插件](./),并保留其中的 `scratch-plugin` 目录。 -## 最小示例 +## 创建工具插件 + +将 `scratch-plugin/src/my-plugin.ts` 替换为: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'my-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { @@ -25,221 +27,26 @@ export function apply(ctx: Context) { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args) { - // args is inferred as { name: string }. return `Hello, ${args.name}!` }, })) } ``` -## 参数定义 +`inject` 让 Cordis 等待工具注册表就绪。`defineTool` 根据 `parameters` 推导并校验 `args`;`execute` 返回 `output.schema` 声明的规范值,`output.render` 再将该值转换为面向模型的内容。 -`parameters` 用一种简洁的格式描述参数,框架会自动转换为模型需要的 JSON Schema。 +## 运行并调用工具 -### 基本类型 +如果开发命令未在运行,请重新启动: -```ts -export const parameters = { - path: { type: 'string', required: true }, - limit: { type: 'integer' }, - recursive: { type: 'boolean' }, - parent: { type: 'null' }, -} -// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null } +```sh +pnpm run dsh web --config ./scratch-plugin/cordis.yml ``` -### 枚举 - -```ts -export const parameters = { - mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, -} -// Inferred type: { mode: 'read' | 'write' | 'append' } -``` - -### 嵌套对象 - -```ts -export const parameters = { - options: { - type: 'object', - additionalProperties: true, - properties: { - timeout: { type: 'number' }, - retries: { type: 'number' }, - }, - }, -} -// The declared fields are inferred; additional JSON-valued keys are allowed. -``` - -### 数组 - -```ts -export const parameters = { - tags: { - type: 'array', - items: { type: 'string' }, - }, -} -// Inferred type: { tags?: string[] } -``` - -### 每个属性的字段 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | 值类型;`json` 接受任意无损 JSON 值 | -| `required` | `true` | 标记为必填(影响类型推导) | -| `description` | `string` | 发送给模型的描述 | -| `enum` / `const` | 匹配类型的标量值 | 允许的字面量值,在编写和运行时边界校验 | -| `properties` | `ParameterSchemaSpec` | 对象的嵌套属性 | -| `additionalProperties` | `true \| false` | 每个显式对象节点都必须声明 | -| `items` | `ValueSchemaSpec` | 数组的元素 schema | -| `oneOf` | 至少两个 `ValueSchemaSpec` 分支 | 要求恰好匹配一个分支;代替 `type` 使用 | - -外层 `parameters` 映射是一个隐式的开放对象。显式嵌套对象需自行选择是否开放;不通过 `defineTool` 注册的原始 JSON Schema 保持 JSON Schema 的默认开放语义。 - -## execute 函数 - -`execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象: - -```ts -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const tool = defineTool({ - name: 'example', - description: 'Return an example result.', - parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - async execute(args, exec) { - // args: inferred from parameters - // exec: ToolExecution context - - // Return the value declared by output.schema. - void args - void exec - return 'result here' - }, -}) -``` - -### 返回值 - -`execute` 返回由 `output.schema` 声明的无损 JSON 值。`output.render(args, value)` 会将经过校验的值另外转换为 Native/模型可见的内容: - -```ts ignore-check -output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string', required: true }, - content: { type: 'string', required: true }, - }, - }, - render: (_args, value) => [{ type: 'text', text: value.content }], -}, -async execute(args) { - return { path: args.path, content: await readFile(args.path, 'utf8') } -} -``` - -执行期间的程序化调用方可以使用规范值,但 `tool/result` 不会持久化该值;渲染后的内容和可选的 `presentationMeta` 才是可回放的投影。工具主体返回的值若不满足 schema 或不是无损 JSON,就会变为 `INVALID_TOOL_OUTPUT` 失败。 - -### 参数校验 - -`defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。 - -你不需要在 `execute` 里手动校验参数类型。 - -## 展示层 (Presentation) - -Tool 可以定义与传输方式无关的展示方法,供终端和 Web 客户端使用: - -```ts ignore-check -defineTool({ - name: 'bash', - // ... - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - presentCall(args) { - return { - card: 'terminal', - title: args.command, - } - }, - presentResult(args, result) { - return { - card: 'terminal', - output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), - } - }, -}) -``` - -`presentCall` 和 `presentResult` 是**纯函数**,不能有副作用——UI 可能在流式传输中和会话回放中多次调用它们。 - -## 注册与卸载 - -`ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。 - -```ts ignore-check -// This is sufficient: -ctx.tools.register(defineTool({ /* ... */ })) - -// No saved disposer or extra cleanup registration is needed. -``` - -## 完整实战示例 - -一个文件计数 tool: - -```ts -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { readdir } from 'node:fs/promises' - -export const name = 'file-counter' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'count_files', - description: 'Count files in a directory.', - parameters: { - path: { type: 'string', required: true, description: 'Directory path' }, - extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, - }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - count: { type: 'integer', required: true }, - files: { type: 'array', required: true, items: { type: 'string' } }, - }, - }, - render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], - }, - async execute(args) { - const entries = await readdir(args.path, { withFileTypes: true }) - let files = entries.filter(e => e.isFile()) - if (args.extension) { - files = files.filter(f => f.name.endsWith(args.extension!)) - } - return { count: files.length, files: files.map(file => file.name) } - }, - })) -} -``` +打开 `http://127.0.0.1:3080`,然后输入:`Use the greet tool to greet Ada.` 模型可以调用 `greet`,并收到 `Hello, Ada!` 这一工具结果。 ## 下一步 -- [插件配置](./config.md) — 让你的 tool 可配置 -- [能力三件套](../practice/) — 了解 seam/impl/consumer 模式 +- [插件配置](./config.md) — 让问候语可配置。 +- [工具编写参考](../../../cookbook/adding-a-tool.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、Code Mode 和 UI 卡片。 +- [能力分层](../practice/) — 将可替换能力拆分为接口、实现和消费方包。 diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index b20764a24d..769556da41 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -events.md: 5cd5d22f854d0b4e271e892cbdb1ccebe687ae49 -events.zh.md: 5fd4d5de53897e32523ab478626965ad7c9602ba +# pnpm run verify-translation-pairing --write docs/user/develop/framework/events.md +events.md: fcbdb5f39bf2078032affbc6469f7eecc795d3ba +events.zh.md: 1979e0bc1dbb71e46f50a051dacee8ae3a1172a7 diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 5cd5d22f85..fcbdb5f39b 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## Cordis events and session records -Harness Cordis events use `namespace/action` names, including `agent/step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. +Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. `turn/*`, `step/*`, `tool/call`, `tool/result`, and `compact/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 5fd4d5de53..1979e0bc1d 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -22,11 +22,11 @@ ctx.emit('event-name', payload) ## 事件模式 -Cordis 提供多种事件触发模式,适用于不同场景: +Cordis 提供多种事件模式,适用于不同的交互契约: ### emit — 广播 -所有监听器同步执行,不关心返回值: +所有监听器同步执行,返回值会被忽略: ```ts ignore-check // Emit @@ -40,7 +40,7 @@ ctx.on('my-plugin/ready', ({ id }) => { ### bail — 短路 -依次调用监听器,第一个返回非 `undefined` 值的结果作为最终值: +依次调用监听器,第一个非 `undefined` 的返回值将作为最终结果: ```ts ignore-check // Dispatch @@ -61,7 +61,7 @@ ctx.on('some-check', (input) => { await ctx.serial('setup-phase', context) ``` -### waterfall — 管道 +### waterfall(瀑布式事件)— 流水线 每个监听器可以包装下游返回值,形成处理链。**必须调用 `next()` 传递给下游**,不调用即为否决: @@ -77,10 +77,10 @@ ctx.on('my-plugin/transform', async (_input, next) => { ``` ::: warning -Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整个管道,这是故意为之的设计——用于实现拦截/网关逻辑。 +waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整个流水线,这是故意为之的设计——用于实现拦截/网关逻辑。 ::: -## Typed Events +## 类型安全的事件 Harness 使用 TypeScript 声明合并来为事件提供类型安全: @@ -101,11 +101,11 @@ declare module 'cordis' { ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 -## 事件也是效果 +## 事件监听器也是效果 通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除: @@ -116,9 +116,9 @@ export function apply(ctx: Context) { } ``` -## 实战示例:日志插件 +## 示例:日志插件 -一个记录所有 tool 调用的简单插件: +这个插件记录工具调用和工具结果: ```ts import type { Context } from 'cordis' @@ -139,5 +139,5 @@ export function apply(ctx: Context) { ## 下一步 -- [能力三件套](../practice/) — 事件在 capability seam 中的角色 -- [LLM 适配器](../practice/llm-adapter.md) — 实现一个完整的 LLM 后端 +- [能力分层](../practice/) — 了解能力接口中的事件 +- [LLM(大语言模型)适配器](../practice/llm-adapter.md) — 实现一个完整的 LLM 后端 diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml index 1712837d16..d06be13bdd 100644 --- a/docs/user/develop/framework/index.i18n.yaml +++ b/docs/user/develop/framework/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md index.md: 79e925b54509da41535735527e283850384257ec -index.zh.md: 62be8c706510704f7b07286f166f14fa81235a0a +index.zh.md: 962677dc468c9cc233a51d50758247e028d9c3ed diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md index 62be8c7065..962677dc46 100644 --- a/docs/user/develop/framework/index.zh.md +++ b/docs/user/develop/framework/index.zh.md @@ -2,11 +2,11 @@ [English](index.md) | 中文 -深入了解 Cordis 插件模型和生命周期状态机。 +本页介绍 Cordis 插件模型和生命周期状态机。 ## Fiber 状态机 -每个被加载的插件对应一个 **Fiber**(作用域)。Fiber 有以下状态: +每个被加载的插件都拥有一个 **Fiber** 作用域,其状态如下: ``` PENDING → LOADING → ACTIVE @@ -16,16 +16,16 @@ ACTIVE → UNLOADING → DISPOSED | 状态 | 含义 | |------|------| -| PENDING | 已声明但依赖未就绪 | +| PENDING | 已声明,但所需依赖未就绪 | | LOADING | 依赖就绪,正在执行 `apply` | | ACTIVE | 插件运行中 | | FAILED | `apply` 抛出异常 | -| UNLOADING | 正在卸载,清理中 | +| UNLOADING | 插件正在卸载并释放资源 | | DISPOSED | 已完全卸载 | ## 依赖驱动的加载 -声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪: +声明了 `inject` 的插件会等待所有必需服务就绪: ```ts ignore-check export const inject = ['tools', 'llm'] @@ -35,7 +35,7 @@ export function apply(ctx: Context) { } ``` -如果依赖的服务消失(比如提供者被热替换),插件会被自动卸载(ACTIVE → DISPOSED),待服务恢复后重新加载。 +如果依赖的服务消失(例如提供方被替换时),插件会被自动卸载(ACTIVE → DISPOSED),待服务恢复后重新加载。 ## 自动清理机制 @@ -56,11 +56,11 @@ export function apply(ctx: Context) { 以下操作都会被自动追踪和清理: - `ctx.on(event, handler)` — 事件监听 -- `ctx.tools.register(tool)` — tool 注册 -- `ctx.llm.registerAdapter(names, adapter)` — LLM 适配器注册 +- `ctx.tools.register(tool)` — 工具注册 +- `ctx.llm.registerAdapter(names, adapter)` — LLM(大语言模型)适配器注册 - `ctx.effect(() => cleanup)` — 自定义资源 -插件卸载时,处置器按注册顺序的反向发起,但多个异步处置器会并发执行,不保证逐个完成。存在顺序依赖的清理步骤必须放进同一个 `ctx.effect()` 返回的处置器中,由该处置器负责串行等待。 +插件卸载时,处置器按注册顺序的逆序开始调用,但多个异步处置器会并发执行,不保证逐个完成。存在顺序依赖的清理步骤必须放进同一个 `ctx.effect()` 返回的处置器中,由该处置器负责串行等待。 ## 嵌套上下文 @@ -75,7 +75,7 @@ export function apply(ctx: Context) { } ``` -## dispose 语义 +## dispose(资源释放)语义 当你需要提前终止一个插件实例: @@ -92,21 +92,21 @@ await fiber.dispose() ``` `dispose` 保证: -1. 该插件注册的所有东西被撤销 +1. 该插件拥有的所有注册均被移除 2. 它的子插件也被递归卸载 -3. 所有异步清理完成后 Promise resolve +3. 返回的 Promise 会在所有异步清理完成后兑现 -## 热替换 (HMR) +## HMR(热模块替换) -在开发环境中(`cordis.yml` 加载了 `@cordisjs/plugin-hmr`),修改插件源文件会自动触发: +通过 `cordis.yml` 加载 `@cordisjs/plugin-hmr` 后,修改插件源文件会触发: 1. 卸载旧插件(清理所有注册) 2. 重新加载新代码 3. 执行新的 `apply` -因为所有注册都会被自动清理,所以热替换天然安全——不会留下旧状态。 +因为插件注册会被自动清理,所以热替换不会保留旧实例的注册。 -## 实战:理解生命周期 +## 生命周期示例 ```ts ignore-check export function apply(ctx: Context) { @@ -132,5 +132,5 @@ effect cleaned up ## 下一步 -- [服务与依赖](./service.md) — 让你的插件对外提供能力 -- [事件系统](./events.md) — 插件间通信的核心机制 +- [服务与依赖](./service.md) — 让插件向其他插件提供能力 +- [事件系统](./events.md) — 在插件之间通信 diff --git a/docs/user/develop/framework/service.i18n.yaml b/docs/user/develop/framework/service.i18n.yaml index f0deb18959..ade6b8dc08 100644 --- a/docs/user/develop/framework/service.i18n.yaml +++ b/docs/user/develop/framework/service.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/user/develop/framework/service.md service.md: 1bf28cb3c7dfdfbd6d0babfa3b1688ac65eea01e -service.zh.md: 17785c056ab9a0a21974e6ed8bbe7f7de05fa00e +service.zh.md: 78b9887a734b235bcff1d94a623e37cfeb2f41e3 diff --git a/docs/user/develop/framework/service.zh.md b/docs/user/develop/framework/service.zh.md index 17785c056a..78b9887a73 100644 --- a/docs/user/develop/framework/service.zh.md +++ b/docs/user/develop/framework/service.zh.md @@ -2,7 +2,7 @@ [English](service.md) | 中文 -服务 (Service) 是插件对外暴露能力的方式。依赖 (inject) 是插件声明自己需要哪些服务。 +服务是一个插件向其他插件公开的能力。inject 声明插件需要哪些服务。 ## 什么是服务 @@ -14,7 +14,7 @@ ctx.llm // LLM service ctx.agents // Agent service ``` -任何插件都可以提供一个新服务,供其他插件使用。 +任何插件都可以提供服务,供其他插件使用。 ## 使用服务 @@ -52,7 +52,7 @@ export default class MetricsService extends Service { } ``` -加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它: +加载这个插件后,消费方就可以通过 `ctx.metrics` 访问它: ```ts ignore-check export const inject = ['metrics'] @@ -86,7 +86,7 @@ export default class MetricsService extends Service { ## 依赖的行为 -### 必选依赖 vs 可选依赖 +### 必需依赖与可选依赖 ```ts ignore-check // Required: the plugin does not load while the service is absent. @@ -101,12 +101,12 @@ export function apply(ctx: Context) { ### 服务消失时的行为 -如果一个必选依赖的服务在运行时消失(比如提供者被卸载): +如果应用运行期间某项必需服务消失(例如其提供方卸载): -1. 依赖它的插件自动 dispose +1. 依赖它的插件会自动 dispose(资源释放) 2. 当服务重新出现时,插件自动重新加载 -这保证了不会出现"调用一个已不存在的服务"的情况。 +这可以防止插件调用已不存在的服务。 ## 服务隔离 @@ -136,13 +136,13 @@ export function apply(ctx: Context) { - name: './src/plugin-b.ts' ``` -`plugin-a` 和 `plugin-b` 各自看到自己组内的 bash 实例,互不影响。 +`plugin-a` 和 `plugin-b` 各自看到自己组内的 Bash 实例,互不影响。 ## Harness 内置服务 -服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 +仓库会自动生成[服务目录](../../../cordis-catalog/services.md),其中包含服务名、公开方法和源码位置。开发插件时应以该目录和服务的 TypeScript 接口为准,不要维护另一份静态清单。 ## 下一步 - [事件系统](./events.md) — 插件间松耦合通信 -- [能力三件套](../practice/) — 服务在 seam 模式中的应用 +- [能力分层](../practice/) — 将服务用作能力接口 diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index 799dffc1c6..65057da78e 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -index.md: e197d499d7f5bd9911ea60bebf584251cd4ed915 -index.zh.md: 8b8d08f9d0c6d0ca8d95fbaa3281c98b7a600fe4 +# pnpm run verify-translation-pairing --write docs/user/develop/practice/index.md +index.md: c3306725f47993aa9d3322423261a260754c1d5f +index.zh.md: 3609cbce21e17ce9ca0a40b69999b293dee29012 diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index e197d499d7..c3306725f4 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -2,6 +2,10 @@ English | [中文](index.zh.md) +This page has two parts: a concept reference for the three-layer capability pattern, followed by an advanced tutorial that builds one capability. Complete the [basic plugin path](../basic/) and [services tutorial](../framework/service.md) first. + +## Concept reference + When a capability is general enough to need replaceable implementations, such as Bash execution, Harness splits it into three packages: an **interface**, an **implementation**, and a **consumer**. Each layer can evolve or be replaced independently. ## Bash example @@ -32,10 +36,7 @@ One interface can have multiple implementations selected through `cordis.yml`: # Local execution - name: '@deepseek-ai/dsh-bash-local' -# Or a future remote sandbox implementation -# - name: '@deepseek-ai/dsh-bash-remote' -# config: -# endpoint: 'https://sandbox.example.com' +# Replace this row with another package that implements the same service. ``` The interface and tool remain unchanged while the implementation changes. @@ -52,17 +53,9 @@ The interface and tool remain unchanged while the implementation changes. - The consumer depends on the interface. - The implementation and consumer **do not depend on each other**. -## Built-in three-layer capabilities +The [capability-seam reference](../../../capability-seams.md) owns the current built-in families and package links. -| Capability | Interface | Implementation | Consumer | -|------|-------------|------|---------------| -| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` | -| Filesystem | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | -| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | -| Subagent | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | -| Compaction | `dsh-compact` | `dsh-compact-basic` | The implementation consumes agent-loop extension events | - -## Develop a three-layer capability +## Tutorial: develop a three-layer capability ### Step 1: define the interface diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 8b8d08f9d0..3609cbce21 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -2,15 +2,19 @@ [English](index.md) | 中文 -当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。 +本文分为两部分:先参考三层能力模式的概念,再通过高级教程构建一项能力。请先完成[基础插件路径](../basic/)和[服务教程](../framework/service.md)。 + +## 概念参考 + +当一项能力足够通用,需要支持可替换的实现时(例如 Bash 执行),Harness 会将其拆成三个包:**接口**、**实现**和**消费方**。这样便可独立替换其中任何一层。 ## 以 Bash 为例 -考虑 "Bash 执行" 这个能力: +以 Bash 执行能力为例: -- **接口** (`dsh-bash`) — 定义"bash 执行"长什么样:输入是什么、输出是什么 -- **实现** (`dsh-bash-local`) — 真正在本地跑命令的代码 -- **消费者** (`dsh-tool-bash`) — 把这个能力包装成模型能调用的 tool +- **接口** (`dsh-bash`):定义 Bash 请求和结果的结构 +- **实现** (`dsh-bash-local`):在本地计算机上执行命令 +- **消费方** (`dsh-tool-bash`):将该能力公开为模型可调用的工具 ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ @@ -32,37 +36,26 @@ # Local execution - name: '@deepseek-ai/dsh-bash-local' -# Or a future remote sandbox implementation -# - name: '@deepseek-ai/dsh-bash-remote' -# config: -# endpoint: 'https://sandbox.example.com' +# Replace this row with another package that implements the same service. ``` -接口不变、tool 不变,只换实现。 +更换实现时,接口和工具均保持不变。 ### 独立演进 - 接口定义稳定后很少改动 - 实现可以独立优化(性能、安全) -- 消费者(tool)可以调整对模型的呈现方式 +- 消费方可以调整能力向模型呈现的方式。 ### 依赖解耦 -- 实现 depend on 接口 -- 消费者 depend on 接口 -- 实现和消费者**互不依赖** +- 实现依赖接口。 +- 消费方依赖接口。 +- 实现和消费方**互不依赖**。 -## Harness 中内置的三件套 +当前内置系列及其包链接由[能力 seam 参考](../../../capability-seams.md)负责。 -| 能力 | 接口 (seam) | 实现 | 消费者 (tool) | -|------|-------------|------|---------------| -| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` | -| 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | -| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | -| 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | -| 压缩 | `dsh-compact` | `dsh-compact-basic` | 由实现插件消费 agent-loop 的扩展事件 | - -## 开发你自己的三件套 +## 教程:开发三层能力 ### 第一步:定义接口 @@ -115,7 +108,7 @@ export function apply(ctx: Context) { } ``` -### 第三步:编写消费者 (tool) +### 第三步:编写消费方 ```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts @@ -153,10 +146,10 @@ export function apply(ctx: Context) { ## 设计要点 -- **不要预防性拆分** — 只有当你确实需要可替换实现时才拆三件套。一个简单的 tool 插件不需要拆分。 -- **接口定义 Request/Result 类型** — 实现和消费者只依赖接口包。 -- **Explicit > Implicit** — 实现中的默认值处理应该是显式的 `resolve(request): Spec` 步骤,不是隐藏在 `run()` 中的 `?? default`。 +- **不要预防性拆分**:只有确实需要可替换实现时,才拆分为三个包。简单的工具插件无需拆分。 +- **接口拥有 Request/Result 类型**:实现和消费方只依赖接口包。 +- **显式优于隐式**:实现应通过显式的 `resolve(request): Spec` 步骤处理默认值,而不是在 `run()` 中隐藏 `?? default`。 ## 下一步 -- [LLM 适配器](./llm-adapter.md) — 实现一个 LLM 后端(最常见的 seam 扩展) +- [LLM 适配器](./llm-adapter.md):实现一个 LLM 后端,这是一种常见的能力 seam 扩展 diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 0044ac720e..7ea31ce419 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 -llm-adapter.zh.md: c30200314a01f7a61d48e3f47288013c31e5aef4 +llm-adapter.zh.md: e491bc0b6ef633b0b40cb094faf07fc84445c026 diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index c30200314a..e491bc0b6e 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -2,11 +2,11 @@ [English](llm-adapter.md) | 中文 -本文介绍如何为 Harness 接入一个新的 LLM 提供方。 +本文介绍如何为 Harness 接入新的模型提供方。 ## 概述 -LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 +LLM 适配器是一个继承 `LlmAdapter` 并实现 `stream()` 方法的类,它会将 Harness 的提供方无关请求转换为具体提供方的 API 调用,并将响应转换回 Harness 分片。 ## 最小实现 @@ -51,7 +51,7 @@ export function apply(ctx: Context, config: Config) { ## StreamChunk 协议 -`stream()` 必须按以下协议 yield chunk: +`stream()` 必须按以下协议生成分片: ```ts import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' @@ -102,17 +102,17 @@ async function* exampleChunks(): AsyncIterable<StreamChunk> { ### 关键规则 -- 每个 `block-start` 必须有对应的 `block-end` -- `index` 从 0 递增,标识内容块顺序 -- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) -- `finish` 必须是最后一个 chunk -- `usage` 在 `finish` 之前 yield +- 每个 `block-start` 都必须有与之对应的 `block-end`。 +- `index` 从 0 开始递增,用于标识内容块的顺序。 +- `tool-call-delta` 的 `argumentsDelta` 是原始 JSON 文本的增量,可以在一个分片中完整生成,也可以分多个分片生成。 +- `finish` 必须是最后一个分片。 +- `usage` 必须在 `finish` 之前生成。 ## GenerateOptions -`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型、适配器拥有的推理强度 ID、对话历史、系统提示词、工具 schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;如果无法支持某个字段,应抛出带稳定 code 的 `LlmError`,不得静默丢弃。 -请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,而不要将这些值提升为核心枚举。异步查询必须响应这个可选信号,让取消和资源释放都能达到完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。 +请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,不要将这些值提升为核心枚举。异步查询必须响应该可选信号,使取消和资源释放过程完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。 ## 注册适配器 @@ -120,7 +120,7 @@ async function* exampleChunks(): AsyncIterable<StreamChunk> { ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) ``` -第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 +第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会将请求路由到该适配器。 ## 在 cordis.yml 中使用 @@ -145,7 +145,7 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) ## 实战参考 -仓库中有两个完整实现可供参考: +仓库中包含以下两个完整实现: - `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) @@ -154,7 +154,7 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) ## 错误处理 -适配器应将传输和协议故障作为带稳定 code 的 `LlmError` 抛出;agent loop 会保留该错误及其 code,供诊断和策略使用。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 +适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障;agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 ```ts import { diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 7d99e45437..372e0e6c73 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 4e438cc3a400de71934d047108024ff5be4ef7d2 -config.zh.md: e0b0285b110a808b1284209a84f78e114634df52 +config.md: ddf4df264e5534fc3b74991941c2f3f82376d53f +config.zh.md: 56ac0146ddae83dbfc86f479030efdb5772a3aaf diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 4e438cc3a4..ddf4df264e 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -47,7 +47,7 @@ A minimal configuration is a list of plugin entries: toolName: my_tool ``` -Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. +Cordis starts sibling entries concurrently. A plugin declares required services through `inject`; Cordis waits for those services before applying the plugin, so file order does not establish dependency readiness. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. ## CLI overlays diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index e0b0285b11..56ac0146dd 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -2,7 +2,7 @@ [English](config.md) | 中文 -Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 +Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 ## 从真实配置开始 @@ -47,7 +47,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 toolName: my_tool ``` -插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 +Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务;Cordis 会等到这些服务就绪后再应用该插件,因此文件顺序不能保证依赖已就绪。引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 ## CLI 覆盖层 diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml index b3fc8da2d2..619c549462 100644 --- a/docs/user/index.i18n.yaml +++ b/docs/user/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write +# pnpm run verify-translation-pairing --write docs/user/index.md index.md: e9a1f03785c7472c47550ec59ea0165d28d3d9a6 -index.zh.md: 907f1452c9ff50d619989c18dcf2727addb2573d +index.zh.md: aba42d79d36e7f5c2e6833f609e48f7b2a79f813 diff --git a/docs/user/index.zh.md b/docs/user/index.zh.md index 907f1452c9..aba42d79d3 100644 --- a/docs/user/index.zh.md +++ b/docs/user/index.zh.md @@ -2,7 +2,7 @@ layout: home hero: name: DeepSeek Harness - text: 插件化 Agent 开发框架 + text: 插件化 agent(智能体)开发框架 tagline: 基于 Cordis 微内核,一切皆插件 actions: - theme: brand @@ -15,9 +15,9 @@ features: - title: 插件化架构 details: 基于 Cordis 插件系统,所有能力通过插件注册,加载即生效、卸载即还原。 - title: 配置即组合 - details: 一个 cordis.yml 决定整个 Agent 的能力组合——换模型、加工具,只需改一行配置。 + details: 一个 cordis.yml 决定整个 agent 的能力组合——换模型、加工具,只需改一行配置。 - title: 开箱即用 - details: 内置 LLM 调用、文件读写、Bash 执行、子代理委派等完整工具链,复制模板即可运行。 + details: 内置 LLM(大语言模型)调用、文件读写、Bash 执行、subagent 委派等完整工具链,复制模板即可运行。 --- # DeepSeek Harness diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 5509012e3e..0cbbafeec9 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -web-styling.md: af05faca30fc968828f5a850f59d9d48ae382b05 -web-styling.zh.md: d0838cd8a6ee4290cdddff16b979950bec396314 +# pnpm run verify-translation-pairing --write docs/web-styling.md +web-styling.md: 5296cc7f83f712532262eadda6da098ae9f63ec7 +web-styling.zh.md: 6beec368f2f7fd5eb7606acfaae315d56899c739 diff --git a/docs/web-styling.md b/docs/web-styling.md index af05faca30..5296cc7f83 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -1,109 +1,25 @@ -# Web GUI Style Guide +# Web UI style reference English | [中文](web-styling.zh.md) -> **[The token system has been replaced—the table in § 1 is retained only for historical reference]** The `--bg-*`/`--text-*`/`--accent` token families documented here and their host package, `packages/client/web-ui`, were retired during the plugin refactor. The sole current token source is `packages/client/ui-theme/src/styles/`, which defines the `--dsw-*` system (a static color scale plus a semantic alias layer, with dark-mode overrides under `body[data-ds-dark-theme]`). The sheet is authoritative and component audits use it as the baseline. **The following rules remain in force**: CSS Modules + clsx, no component library, no Tailwind, no hard-coded color values in components, pair every font size with a line height, use spacing in multiples of 4, and do not put monospace last in the code font stack. +This reference defines styling ownership and component rules for browser client packages. The current token values live in [`packages/client/ui-theme/src/styles/`](../packages/client/ui-theme/src/styles/); this document does not duplicate that generated-by-source inventory. -> Status: formerly a “living document” that evolved with `packages/client/web-ui`. The visual baseline came from empirical study of the deepseekchat frontend repository. The [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) owns the framework decisions and engineering constraints; this document does not repeat their rationale. +## Ownership -## 1. Design token table (authoritative definitions) +[`ui-theme`](../packages/client/ui-theme/README.md) owns the `--dsw-*` static scale, semantic aliases, typography, motion, gradients, shadows, scrollbar styles, and light/dark preference. [`ui-layout`](../packages/client/ui-layout/README.md) applies the resolved theme snapshot to the document. Feature packages consume semantic aliases and do not define another global theme. -All tokens live in `packages/client/web-ui/src/style/global.css`: `:root` contains the light-theme values, and the `[data-theme='dark']` block overrides the same variables (columns that were not complete are marked as placeholders). Component CSS references tokens only and contains no literal color values. +Global style sheets belong in `ui-theme/src/styles/`. Component styles live beside their component as CSS Modules. A component may define a local custom property when its value is part of that component's layout or presentation contract; shared colors, typography, elevation, and motion belong to the theme package. -### 1.1 Colors (two layers: comments identify the base-palette source, while variable names are semantic aliases) +## Component rules -| token | Light value | Dark value (placeholder) | Purpose | -| --- | --- | --- | --- | -| `--bg-base` | `#ffffff` | `#151517` | Page background | -| `--bg-layer` | `#ffffff` | `#232324` | Floating layer/panel | -| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | Sidebar background | -| `--text-primary` | `#0f1115` | `#f9fafb` | Body text | -| `--text-secondary` | `#61666b` | `#cfd3d6` | Secondary text | -| `--text-tertiary` | `#81858c` | `#adb2b8` | Supporting/descriptive text | -| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | Subtle separator (sidebar right edge) | -| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | Standard border | -| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | Hover-state background | -| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | Pressed/active-state background | -| `--accent` | `#3964fe` | `#5686fe` | Brand blue (deepseek-500; one step lighter in dark mode) | -| `--accent-soft` | `#edf3fe` | `#28313f` | Soft brand background (emphasis blocks) | -| `--accent-item` | `#e4edfd` | `#35363a` | Selected sidebar-item background | -| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | User-message bubble background | -| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | Same values | Semantic status colors | -| `--text-on-solid` | `#ffffff` | Same value | Text on solid backgrounds (accent/error badges, etc.) | -| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | Soft semantic status backgrounds (badges); green-100/red-100, with the 900 shades in dark mode | -| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | Same values | RPC debugger direction colors (project-specific, not part of the baseline) | -| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | Same colors at `.24` | Soft direction-color backgrounds (badges) | -| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | Scrollbar colors (for `.scrollable` only) | +- Use CSS Modules and `clsx`; do not add a component library or Tailwind. +- Use `--dsw-alias-*` semantic tokens in feature components. Do not copy static palette values or write literal colors there. +- Keep theme selectors out of feature component CSS. Light/dark overrides belong to the theme owner. +- Pair font sizes with line heights and use the theme typography variables when an existing role matches. +- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors. +- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches. +- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. -### 1.2 Non-color tokens +## Changing the system -| token | Value | Description | -| --- | --- | --- | -| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | Body-text stack | -| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | Code stack; **do not put monospace last** (prevents SimSun fallback for Chinese on Windows) | -| `--fw-strong` | `600` | Unified bold weight | -| `--ease` | `cubic-bezier(.4,0,.2,1)` | Sole easing curve | -| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | Three transition durations | -| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | Semantic radius steps: small controls / list items and blocks inside panels / floating layers / bubbles / input cards (same as the baseline inputWrapper); use `999px` directly for pills | -| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | Floating-layer shadow (baseline lv3) | -| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | Strong floating panel (enhanced lv3, such as the RPC debugger overlay) | -| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`; dark value `none` | Subtle input-card shadow (baseline: borders plus a subtle shadow distinguish same-color light surfaces; a lighter background distinguishes dark surfaces, with the shadow disabled) | - -Font sizes and spacing are **not tokenized** (matching the baseline repository's decision): components specify font sizes in px and **always pair them with line heights**. Common pairs are 16/24 (bubbles), 14/22 (UI default), and 12/18 (supporting text); spacing uses multiples of 4. - -## 2. Visual baseline (from deepseekchat) - -- Sidebar: width `260px + 1px` right border (`--border-l1`); background `--bg-sidebar`. -- Sidebar items: height `40px`, radius `--radius-m`, font size 14px; hover background `--hover-bg` or a sidebar-specific gray; **selected items use `--accent-item` without changing text color**. -- Sidebar group headings: 12px / weight 500 / `--text-tertiary` / sticky at the top (using the sidebar background to cover scrolling content). -- Conversation column: centered at `max-width: 840px`, reduced to 712px below 1024px. -- Message stream: **only user messages have bubbles**: background `--bubble-bg`, radius `--radius-bubble`, padding `10px 16px`, font size 16px/24px, and `max-width: calc(100% - 88px)`; **assistant messages are a plain document flow without a background**. -- Message action bar: `opacity: 0` by default; fades in when its parent is hovered or contains focus (`--dur` + `--ease`). -- Input card: floats centered at the same width as the conversation column (840px, reduced to 712px below 1024px) with bottom spacing; radius `--radius-xl`, border `--border-l2`, background `--bg-base`, shadow `--shadow-card`; two internal vertical sections = textarea (16px/24px, minimum 2 lines, maximum 14 lines = 336px, auto-growing through a mirror div) + action row (a 34px primary round button nested at bottom right); focus does not change the border or shadow (matching the baseline). -- Primary input button (the three decisions made on 2026-07-20, visually based on the Codex App): a 32px solid circular icon button (inline SVG). Idle = `--accent` background with a white ↑ “Send” arrow; while running it changes in place to an accent ■ “Stop” icon on `--accent-soft` (the same color family, not a warning, and not red). **Input is locked while running** (decision 3, replacing the earlier hover-menu design): the textarea is disabled (gray, with draft content still visible), there is no queue/interjection menu, and Stop is the only action. When the turn ends, input unlocks and regains focus. Enter sends; Ctrl/Meta+Enter inserts a newline (the keyboard path is disabled with the locked input while running). -- Scrollbars: nearly invisible, darkening on hover, with `scrollbar-gutter: stable` so they do not consume layout space (always use `.scrollable`; see § 3.9). -- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = unary, double line = SSE): - -| Symbol | Quadrant | Badge colors | -| --- | --- | --- | -| `↑` | client-request (unary outbound) | `--accent` / `--accent-soft` | -| `↓` | server-response (unary response) | ok `--ok`/`--ok-soft`, error `--error`/`--error-soft` | -| `⇟` | server-request (SSE frame push) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response (SSE-side response) | `--accent`/`--accent-soft` at reduced opacity | - -## 3. Style implementation rules (review checklist) - -1. Colors, radii, motion, and font stacks reference only the § 1 tokens. Reject literal color values in component CSS (except for special effects such as gradient masks, which require an explanatory comment). -2. Component CSS must not contain `[data-theme]` selectors; dark-mode differences belong only in the global.css token table. If a theme must change a non-token value such as a gradient endpoint, define a local CSS variable in the component and have the theme block override only that variable (a variable bridge). -3. Use camelCase class names; use a single adjective for state classes (`.active` `.show`) and attach them with clsx: `clsx(styles.x, cond && styles.active, className)`. -4. Public components must accept `className` and merge it into the root element. -5. Do not use `composes`; share through tokens and extracted components. -6. Use `:global` only to pierce third-party or cross-package class names; do not use it to define new global classes. -7. All interaction transitions use `var(--dur*) var(--ease)` and transition only opacity / transform / background color / shadow. Wrap hover-only reveal elements in `@media (hover: hover)`. -8. Prefer opacity-based tokens for hover/active backgrounds because they compose over any elevation background; do not add new solid grays. -9. Apply the `.scrollable` utility class from global.css to every scroll container; do not write `::-webkit-scrollbar` inside components. -10. Put media queries at the end of the component CSS, next to the rules they override. The only current breakpoint is 1024px (where the conversation column steps down); record a second breakpoint in this document before adding it. -11. Dynamic styles in JS set only CSS variables (`style={{'--x': v}}`), while rules remain in CSS; do not assemble style objects in TSX to branch by theme or state. -12. Use only the three `--text-primary/secondary/tertiary` levels for gray text; do not add another gray. - -## 4. File organization - -- `src/style/global.css` always uses this section order: ① token table (`:root` + `[data-theme='dark']`), ② global foundations (box-sizing, body, button reset), ③ global utility classes (`.scrollable`, etc.; keep the total in single digits). -- Place each `*.module.css` beside the component with the same name; use one module file per component. -- Use the existing `css-modules.d.ts` wildcard declaration. Reassess introducing tcm to generate exact `.css.d.ts` files only after the component count exceeds 20. -- PostCSS feature allowlist: currently **no plugins** (flat CSS plus native nesting when needed). Record nested/custom-media in this document before introducing either. - -## 5. Evolution rules and deviation log - -- **Adding a token**: add it to the § 1 table first (including the dark-placeholder column), then use it in the component. Reject any new `--` variable that has not been added to the table (except for component-local variable bridges). -- **Deviating from the baseline**: if an implementation differs from any constant in § 2, add one row to the deviation table below (date / item / rationale). -- **Dark-table completion acceptance**: after `[data-theme='dark']` overrides every placeholder column in § 1, compare the RPC panel, sidebar, and conversation stream manually or with screenshots. Acceptance requires all three to match and no component-level theme selector to remain. - -| Date | Deviation | Rationale | -| --- | --- | --- | -| (none) | | | - -## 6. Related documentation - -- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) (decision record for the five framework rules and engineering constraints) -- Client consumption architecture and layered protocols: [Web client architecture RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md), [GUI layering and RPC protocol RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) +Add or change a shared token in the owning `ui-theme` sheet, then consume its semantic alias from feature packages. Update the owning package reference when a public styling contract changes. Visual behavior follows the [testing policy](testing.md); the [styling-system Agent Note](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) records framework rationale. diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index d0838cd8a6..6beec368f2 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -1,109 +1,25 @@ -# Web GUI 样式规范 +# Web UI 样式参考 [English](web-styling.md) | 中文 -> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 +本文规定浏览器客户端包的样式职责归属与组件规则。当前 token 值位于 [`packages/client/ui-theme/src/styles/`](../packages/client/ui-theme/src/styles/);本文不重复这份由源码生成的清单。 -> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 +## 职责归属 -## 1. 设计 token 表(权威定义) +[`ui-theme`](../packages/client/ui-theme/README.md) 负责 `--dsw-*` 静态色阶、语义别名、排版、动效、渐变、阴影、滚动条样式以及明暗主题偏好。[`ui-layout`](../packages/client/ui-layout/README.md) 将解析后的主题快照应用到文档。功能包使用语义别名,不得另行定义全局主题。 -所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。 +全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于组件自身的布局或呈现契约时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 -### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名) +## 组件规则 -| token | 亮色实值 | 暗色(占位) | 用途 | -| --- | --- | --- | --- | -| `--bg-base` | `#ffffff` | `#151517` | 页面底 | -| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 | -| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 | -| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 | -| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 | -| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 | -| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) | -| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 | -| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 | -| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 | -| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) | -| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) | -| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 | -| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 | -| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 | -| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 | -| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 | -| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) | -| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) | -| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) | +- 使用 CSS Modules 和 `clsx`;不得添加组件库或 Tailwind。 +- 功能组件使用 `--dsw-alias-*` 语义 token。不得复制静态色板值或在其中写入颜色字面量。 +- 功能组件 CSS 不得包含主题选择器。明暗主题覆盖属于主题所有方。 +- 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。 +- 当组件契约要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。 +- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。 +- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。 -### 1.2 非颜色 +## 变更系统 -| token | 值 | 说明 | -| --- | --- | --- | -| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 | -| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) | -| `--fw-strong` | `600` | 粗体统一权重 | -| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 | -| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 | -| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` | -| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) | -| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) | -| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) | - -字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。 - -## 2. 视觉基线(源自 deepseekchat) - -- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。 -- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。 -- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。 -- 会话列:`max-width: 840px` 居中,<1024px 降 712px。 -- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。 -- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。 -- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 -- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 -- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 -- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): - -| 符号 | 象限 | 徽章配色 | -| --- | --- | --- | -| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | -| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | -| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | - -## 3. 样式编码规范(review 对照打勾) - -1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。 -2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。 -3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。 -4. 对外组件必须透传 `className` 并合入根元素。 -5. 禁用 `composes`;复用靠 token 与组件抽取。 -6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。 -7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。 -8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。 -9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。 -10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。 -11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。 -12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。 - -## 4. 文件组织 - -- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。 -- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。 -- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。 -- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。 - -## 5. 演进规则与偏离记录 - -- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。 -- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。 -- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。 - -| 日期 | 偏离项 | 理由 | -| --- | --- | --- | -| (空) | | | - -## 6. 相关文档 - -- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录) -- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) +在所属 `ui-theme` 样式表中添加或修改共享 token,然后在功能包中使用其语义别名。公共样式契约发生变化时,更新所属包的参考文档。视觉行为遵循[测试策略](testing.md);[样式系统 Agent Note](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 记录框架依据。 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index ead468e816..f898c19e9a 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md -README.md: 64e9804eb69367588e926791039c453b0a9aede9 -README.zh.md: dd26b9e3f35c2d3350da77ce04bd77b4660ced1b +README.md: 5d021d9d9c7abae90b5f96bccd6447f4e2c3dc57 +README.zh.md: 66b355a93c0a0e6b53d1353de4024b7f86e82f7c diff --git a/examples/README.md b/examples/README.md index 64e9804eb6..5d021d9d9c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,32 +2,24 @@ English | [中文](README.zh.md) -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +Runnable demonstrations of the main DeepSeek Harness interfaces and extension points. Each child directory owns its configuration, prerequisites, commands, and detailed behavior. ## mcp-memory -Three default-off reference overlays connect a memory MCP server through the generic MCP client. Pick one file and pass it to `dsh --config`; DSH does not install or configure the upstream memory system. See [mcp-memory/README.md](mcp-memory/README.md) for pinned prerequisites, identity mapping, the shared optional prompt, and the write → fresh-session recall → use verification recipe. +Optional overlays that connect supported third-party memory servers through the generic MCP client. See the [memory example reference](mcp-memory/README.md). ## headless-agent -A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. - -Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. +A non-interactive agent that accepts one task, runs it, and emits a selected machine-readable or human-readable output format. See the [headless example reference](headless-agent/README.md). ## jsonrpc-agent -An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). +An unattended coding agent driven through the Python SDK and JSON-RPC. See the [JSON-RPC example reference](jsonrpc-agent/README.md). ## web-cordis -The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. - -Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or the ACP server with `pnpm run demo:cordis acp` (both need `DEEPSEEK_API_KEY`). See [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. +A self-referential agent that can inspect and change its in-memory Cordis plugin tree. See the [web-cordis example reference](web-cordis/README.md). ## acp-agent -An agent exposed as an **Agent Client Protocol (ACP)** automation server over JSON-RPC stdio, via [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo). Programmatic clients create fresh sessions, send text prompts, consume committed assistant text, answer one-shot permission requests, and cancel work. It owns the ACP keyless snapshot suite. - -Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the protocol and snapshot-test contracts. - -The default `cordis.yml` composes [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), and [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval). `workspace-write` confines bash and filesystem mutations to each session workspace; a wider retry becomes a one-shot machine permission request over ACP. +An Agent Client Protocol automation server for programmatic clients, with session, permission, and cancellation support. See the [ACP example reference](acp-agent/README.md). diff --git a/examples/README.zh.md b/examples/README.zh.md index dd26b9e3f3..66b355a93c 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,32 +2,24 @@ [English](README.md) | 中文 -展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +展示 DeepSeek Harness 主要接口和扩展点的可运行演示。每个子目录负责自己的配置、前置条件、命令和详细行为。 ## mcp-memory -三份默认关闭的参考 overlay 通过通用 MCP 客户端连接一个记忆 MCP 服务器。选择其中一份文件传给 `dsh --config`;DSH 不负责安装或配置上游记忆系统。版本固定的前置条件、身份映射、可选的共用提示词,以及「写入 → 新会话召回 → 使用」验证流程详见 [mcp-memory/README.md](mcp-memory/README.md)。 +通过通用 MCP 客户端连接受支持第三方记忆服务器的可选 overlay。详见[记忆示例参考](mcp-memory/README.md)。 ## headless-agent -非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 - -运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 +非交互式 agent(智能体):接受一项任务并运行,然后以选定的机器可读或人类可读格式输出结果。详见[无头示例参考](headless-agent/README.md)。 ## jsonrpc-agent -通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 +由 Python SDK 和 JSON-RPC 驱动的无人值守编码 agent。详见 [JSON-RPC 示例参考](jsonrpc-agent/README.md)。 ## web-cordis -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 - -使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +能够检查并更改内存中 Cordis 插件树的自指 agent。详见 [web-cordis 示例参考](web-cordis/README.md)。 ## acp-agent -作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 - -运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 - -默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 +面向程序化客户端的 ACP(Agent Client Protocol)自动化服务器,支持会话、权限和取消操作。详见 [ACP 示例参考](acp-agent/README.md)。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index dc603691e1..0bfa1735ce 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/acp-agent/README.md -README.md: a37954d4d52900413a506f227759a0a7dd2a25d0 -README.zh.md: a38431ea2f01fe16ff39b5dd00ec23e881713f5d +README.md: 61c6efafe9dde4f91385beebdfd426c57006187b +README.zh.md: 343e55722a4dda7c5cc5e0de6b5fecf250b9ab13 diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index a37954d4d5..61c6efafe9 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. Optional overlays add session queries, filesystem spill storage, Code Mode, or web fetching. ## Protocol channel @@ -19,12 +19,6 @@ The automation contract — supported methods, baseline prompt content, committe ## Session workspaces and permissions -Each `session/new` supplies an absolute `cwd`. Sandboxed bash and filesystem mutations resolve `workspace-write` against that session cwd, so concurrent sessions can use separate project roots; platform temporary roots remain shared writable scratch space ([sandbox contract](../../packages/sandbox/sandbox/README.md)). `DSH_PERMISSION_MODE` selects `workspace-write` or `danger-full-access` for deployment and tests. +Each `session/new` supplies an absolute `cwd`. Sandboxed bash and filesystem mutations resolve `workspace-write` against that session cwd, so concurrent sessions can use separate project roots; platform temporary roots remain shared writable scratch space ([sandbox contract](../../packages/sandbox/sandbox/README.md)). `DSH_PERMISSION_MODE` selects `workspace-write` or `danger-full-access` for the deployment. Under `workspace-write`, a model retry requesting wider sandbox access triggers `session/request_permission` with `allow_once` and `reject_once`. The client decides programmatically; dismissal or an unavailable answer fails closed. The selected outcome applies only to that retry and is recorded through the normal tool-result/audit path. The server never exposes a permission picker or persists client policy. - -## Snapshot tests - -This example owns the ACP snapshot suite. It boots the real automation server, replays committed model streams through `dsh-llm-replay`, and compares both normalized protocol output and re-persisted session logs. Recording uses the real model; refresh reuses committed replay input. Overrides cover throw/hang behavior, and optional `workspace/` fixtures seed world-state checks. - -Most scenarios pin backend behavior rather than ACP-specific behavior; the [automation-only ACP decision](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) owns why that coverage remains transport-coupled. diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index a38431ea2f..343e55722a 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的面向自动化的 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 服务器。它面向 parent agent(父智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 +通过 JSON-RPC stdio 提供的面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。它面向 parent agent(父智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` -该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。 +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。可选 overlay 可添加会话查询、文件系统溢出存储、Code Mode 或 Web 抓取。 ## 协议通道 @@ -19,12 +19,6 @@ Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` ## 会话 workspace 与权限 -每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 和文件系统修改会以该会话 cwd 为基准应用 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 +每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 和文件系统修改会以该会话 cwd 为基准应用 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 为部署选择 `workspace-write` 或 `danger-full-access`。 在 `workspace-write` 下,如果模型重试请求更广泛的沙箱访问权限,就会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;客户端放弃选择或无法给出答复时,系统会按拒绝处理。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 - -## 快照测试 - -此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖配置涵盖抛错/挂起行为,可选的 `workspace/` fixture 则为环境状态检查预置状态。 - -大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。 diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml new file mode 100644 index 0000000000..af834b885d --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless runner-classification composition: replay authored model turns and +# replace the shipping provider with a deterministic process-launch stand-in. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml new file mode 100644 index 0000000000..7a958bb6de --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -0,0 +1,13 @@ +# Live counterpart for the runner-classification snapshot overlay. It replaces +# only the sandbox provider; authored scenarios are skipped in record mode. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - insert: + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 198daf015d..225b033b56 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,10 +1,12 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' /** @@ -47,6 +49,8 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) +const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -157,6 +161,48 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the + // bundle's bash tool for the PowerShell twin, so its header class pins its + // own prompt/tool sidecars and a recorded transcript. + { + name: 'pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'pwsh', + configPath: PWSH_CONFIG, + // The composition boots the real pwsh executor; hosts without a `pwsh` + // binary skip the run (fixtures stay guarded). The recorded turn writes + // PWSH_OK via [Console]::Out.Write so the fixture carries no platform + // newline and one recording replays on every host. + pwshOnly: true, + }, + // Authored keyless replay through a test-only partial-Landlock provider: + // the exact compatibility notice must stay ordinary stderr when the wrapped + // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. + { + name: 'partial-landlock-child-failure', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { DSH_PERMISSION_MODE: 'read-only' }, + posixOnly: true, + }, + // A valid cwd plus a missing provider executable exercises the assembled + // foreground error and background task marker without a platform runner. + { + name: 'missing-sandbox-runner', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { + DSH_PERMISSION_MODE: 'read-only', + DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER: '1', + }, + posixOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', @@ -356,12 +402,13 @@ const SCENARIOS: Scenario[] = [ // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, // A nested fs dispatch inside run_code discovers workspace instructions. The - // injected user/message must follow the outer result while retaining workspace - // provenance, which proves Code Mode carries deferred tool context end to end. + // projection enters the inbox after the outer result and becomes model-visible + // on the following step, retaining workspace provenance end to end. { name: 'code-mode-workspace-context', hasModelTurn: true, - recorded: true, + recorded: false, + overridden: true, pinsHeader: true, headerClass: 'code-workspace-context', systemPromptSource: 'code-mode-turn', @@ -419,11 +466,17 @@ const SCENARIOS: Scenario[] = [ }, ] +// Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures +// stay guarded); the probe follows the executor's own resolution so a Windows +// host with only an install-location pwsh still runs the scenario. +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: SNAPSHOTS_DIR, scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + hasPwsh, }) it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { @@ -438,15 +491,25 @@ it('packed ACP fixture retains every chunk row kind without changing the logical expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks']) const withoutMessageId = (record: unknown): unknown => { const cloned = structuredClone(record) as { + time?: unknown type?: unknown - data?: { id?: unknown; message?: { id?: unknown } } + data?: { + durationMs?: unknown + id?: unknown + inserted?: Array<{ id?: unknown }> + message?: { id?: unknown } + } + } + delete cloned.time + if (cloned.type === 'agent/inbox/spliced') { + for (const message of cloned.data?.inserted ?? []) delete message.id } if (cloned.type === 'user/message') delete cloned.data?.id if (cloned.type === 'assistant/message' - || cloned.type === 'tool/result' - || cloned.type === 'steering/message') { + || cloned.type === 'tool/result') { delete cloned.data?.message?.id } + if (cloned.type === 'hook/result') delete cloned.data?.durationMs return cloned } const logicalRecords = (records: readonly unknown[]): unknown[] => [ diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml new file mode 100644 index 0000000000..c152b6ca67 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml @@ -0,0 +1,27 @@ +# Minimal tool-pwsh composition: real app boot path, real pwsh executor, real +# foreground + background tool calls; driven by the package's loader.spec.ts. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + graceMs: 200 + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts new file mode 100644 index 0000000000..9899bfcd17 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Test driver: boot the tool-pwsh Loader composition, execute one real + * foreground and one real background pwsh command through the tool registry, + * and persist the observed model-visible output to `./pwsh-loader-report.json` + * for the package spec's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { CallId } from '@deepseek-ai/dsh-llm' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tool-pwsh driver requires a config path') + +const ctx = await boot('tool-pwsh-loader-smoke', resolveConfigPath(configPath, undefined)) +try { + const schema = ctx.tools.schemas().find(tool => tool.name === 'pwsh') + if (schema === undefined) throw new Error('pwsh tool not registered by the composition') + const prompt = (await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:pwsh') + + const foreground = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-fg'), + name: 'pwsh', + arguments: { command: 'Write-Output loader-ok', description: 'loader foreground' }, + }) + const foregroundText = foreground.content.filter(block => block.type === 'text').map(block => block.text).join('') + + const background = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg'), + name: 'pwsh', + arguments: { + command: 'Start-Sleep -Milliseconds 200; Write-Output loader-bg-ok', + description: 'loader background', + run_in_background: true, + }, + }) + const taskId = (background.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so accumulate both. + let backgroundText = '' + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const read = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg-read'), + name: 'task_output', + arguments: { task_id: taskId }, + }) + backgroundText += read.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (backgroundText.includes('loader-bg-ok') && backgroundText.includes('[status: completed')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + + await writeFile('./pwsh-loader-report.json', JSON.stringify({ + schemaHasRunInBackground: Object.hasOwn(schema.parameters.properties as object, 'run_in_background'), + promptHasMarkerSection: prompt?.text.includes('Non-zero exits are reported as `[exit code: N]` markers') === true, + // Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). + foregroundText: foregroundText.replace(/\r\n/g, '\n'), + backgroundText: backgroundText.replace(/\r\n/g, '\n'), + })) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts new file mode 100644 index 0000000000..dd0a2624f1 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -0,0 +1,41 @@ +import { join } from 'node:path' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const MISSING_RUNNER_ENV = 'DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER' + +/** + * Snapshot-only provider for deterministic runner classification. Its default + * launch reproduces older-ABI Landlock; an explicit scenario flag selects a + * missing executable under the valid workspace cwd. Keep the Landlock tuple + * aligned with `RUNNER_FAILURE_RULES` in `packages/sandbox/sandbox-local/src/index.ts`. + */ +export default class PartialLandlockSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + if (process.env[MISSING_RUNNER_ENV] === '1') { + return { + argv: [join(policy.workspaceRoot, '.dsh-missing-sandbox-runner'), ...argv], + enforcement: 'full', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ fatalSignatures: ['snapshot-runner: '] }], + } + } + return { + argv: [ + 'bash', + '-c', + `printf '%s\\n' '${NOTICE}' >&2; exec "$@"`, + 'partial-landlock-run', + ...argv, + ], + enforcement: 'partial', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: [NOTICE], + }], + } + } +} diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 3a27d26b3d..5dd19bb348 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -84,12 +84,16 @@ export function apply(ctx: Context): void { // runs, so the queued FIFO order is what the transcript records. The first // child enqueue is the initial delegation, which also pins the real child id. let accepted = 0 - ctx.on('agent/inbox/enqueue', (agent) => { + ctx.on('agent/inbox/inserted', (agent) => { if (agent.session.header.parentSession === undefined) return if (realChildId === undefined) realChildId = agent.session.header.id accepted += 1 if (accepted >= 3) followupsAccepted.resolve(undefined) }) + ctx.on('agent/pre-step', async (agent, _messages, _context, next) => { + if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise + return next() + }) // The child's ordinary per-turn flushes succeed; only the final continuation // turn's durability checkpoint fails, turning that turn/end into a durable diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/input.json b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json index f81a96af7c..89e231618b 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/input.json +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json @@ -6,6 +6,6 @@ { "op": "waitForTurnStart", "minimumTurn": 3 }, { "op": "cancel", "waitForFile": { "path": ".dsh-snapshot-goal-cancel-ready" } }, { "op": "waitForTurnEnd" }, - { "op": "waitForEventAfterTurnEnd", "type": "user/message" } + { "op": "waitForEventAfterTurnEnd", "type": "goal/change" } ] } diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index 4d24c42596..f7b0cc84b2 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -1,56 +1,62 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable two-round goal","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} -{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"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":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":35,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":36,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} -{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":38,"time":0,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":44,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} -{"type":"step/end","seq":45,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":46,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":47,"time":0,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2}}}} -{"type":"user/message","seq":48,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":49,"time":0,"data":{"turn":3,"step":1}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":52,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":53,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} -{"type":"user/message","seq":54,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"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":0,"data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"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.\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":6,"time":0,"data":{"title":"Create a durable two-round goal","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"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":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"goal/change","seq":16,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"tool/call","seq":26,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}} +{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":37,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":38,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":39,"time":0,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":40,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":41,"time":0,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":42,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":48,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[43,44,45,46,47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":50,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":51,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":52,"time":0,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":53,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":54,"time":0,"data":{"turn":3,"step":1}} +{"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} +{"type":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl index 4355ccb070..46ca2fe819 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -1,50 +1,56 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} -{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":26,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} -{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":28,"time":0,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} -{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} -{"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} -{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} -{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} -{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":46,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} -{"type":"step/end","seq":47,"time":0,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":48,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"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":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"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.\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":6,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"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":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"goal/change","seq":16,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":28,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":29,"time":0,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":30,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":31,"time":0,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":32,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":38,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[33,34,35,36,37],"surfaceOp":"append"} +{"type":"tool/call","seq":39,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} +{"type":"goal/change","seq":40,"time":0,"data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}} +{"type":"tool/result","seq":41,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":42,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"step/end","seq":43,"time":0,"data":{"turn":2,"step":1}} +{"type":"agent/inbox/spliced","seq":44,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":45,"time":0,"data":{"turn":2,"step":2}} +{"type":"user/message","seq":46,"time":0,"data":{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":52,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":54,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index a7757eacf7..898e138a1b 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -152,7 +152,8 @@ describe('same-session goal snapshot through the ACP automation driver', () => { .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP')) expect(closing).toHaveLength(1) const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2) - expect(roundTurnEnds).toEqual([expect.objectContaining({ data: { turn: 2, reason: { kind: 'completed' } } })]) + expect(roundTurnEnds).toHaveLength(1) + expect(roundTurnEnds[0]?.data).toMatchObject({ turn: 2, reason: { kind: 'completed' } }) const context: NormalizeContext = { sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..9daab43aff --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Minimal keyless composition: real app, pwsh executor, and pwsh tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml new file mode 100644 index 0000000000..7021ae2116 --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -0,0 +1,37 @@ +# Minimal live counterpart for the pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 2e7f26fcd3..7908f0e71b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,17 +1,19 @@ {"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":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9769d9fc-4aef-45ae-bea7-dfa1697971ef"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534720685,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"user/message","seq":4,"time":1785534720686,"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"},"role":"user","id":"7fd53a8f-7165-47ea-adcf-84a8d303ec47"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534720686,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534720686,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534720687,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1785464659110,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1785487621180,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785534720695,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1785534720695,"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":"7032baa0-b8a1-4c26-8064-8a95f6fc7309"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785534720695,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":15,"time":1785534720695,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498801881,"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":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} +{"type":"turn/start","seq":1,"time":1785821418076,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821418076,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821418091,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":1785730458555,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730458555,"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":"f9a2d1b6-8f23-43a5-8702-d413fed40990"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730458555,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730458555,"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":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730458561,"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":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730458561,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 1f8b6b8621..adf877c24b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,17 +1,19 @@ {"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":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a9ccad1e-ded5-4f5f-9419-a6bc440e96c3"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534720833,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"user/message","seq":4,"time":1785534720834,"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"},"role":"user","id":"253f3d13-7f37-430b-8dc8-ce9ee53f1ddc"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534720834,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534720835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534720835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1785464659280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1785487621348,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785534720842,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1785534720842,"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":"892d0e12-63cd-4700-aebb-ba610e36bec6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785534720842,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":15,"time":1785534720842,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498802039,"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":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} +{"type":"turn/start","seq":1,"time":1785821418251,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821418251,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821418270,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":1785730458703,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730458703,"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":"dfbcd587-db47-4c3d-bbe9-8c031b215fc3"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730458703,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730458703,"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":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730458709,"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":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730458709,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 7e3b752d11..f4169abe78 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,68 +1,70 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"d65944b9-834c-4b58-a7c7-c0040ce4d9dc"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464658979,"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"},"role":"user","id":"e7b34e36-8267-4ced-884d-b430f8d7fdc6"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464658979,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464658979,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487621044,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464658989,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":11,"time":1785487621053,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487621053,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"51e29fae-29ea-46af-9e9c-d636711048d2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487621053,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":14,"time":1785487621063,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"6f09b45d-faeb-455c-bc74-ce21ae15e79e"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487621063,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487621072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464659011,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":21,"time":1785487621077,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487621077,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4658a53d-fbb3-4df6-bee1-9556eccbb167"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487621077,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":24,"time":1785487621138,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":25,"time":1785487621139,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":26,"time":1785487621142,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"5bc61365-5fbc-4264-83da-f43c54c474f2"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785487621142,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":28,"time":1785487621147,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":29,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":30,"time":1783950000030,"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.\"}"}}} -{"type":"assistant/chunk","seq":31,"time":1785036891179,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":32,"time":1785464659075,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":33,"time":1785487621152,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785487621152,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0102146b-afbd-4bd2-8323-10cedac7e09c"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"tool/call","seq":35,"time":1785487621153,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":36,"time":1785487621188,"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":"8cd6d887-1136-4e07-a9da-5a802455fbc2"}},"sourceEventSeqs":[35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785487621188,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":38,"time":1785487621196,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":40,"time":1783957884594,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} -{"type":"assistant/chunk","seq":41,"time":1785036891211,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} -{"type":"assistant/chunk","seq":42,"time":1785464659132,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":43,"time":1785487621201,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785487621202,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3ec898d2-73da-4083-83c7-165839fd220b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} -{"type":"tool/call","seq":45,"time":1785487621202,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} -{"type":"tool/result","seq":46,"time":1785487621356,"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-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"61c5c122-4d5e-4241-8efe-3159a166050b"}},"sourceEventSeqs":[45],"surfaceOp":"append"} -{"type":"step/end","seq":47,"time":1785487621356,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":48,"time":1785487621365,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":51,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":52,"time":1785464659304,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":53,"time":1785487621370,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785487621370,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"27d4f9ae-6c7e-4e48-bbe9-3e6f625c0767"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} -{"type":"tool/call","seq":55,"time":1785487621370,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":56,"time":1785487621377,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"aed1d952-b136-4707-968f-f281bf94d9e3"}},"sourceEventSeqs":[55],"surfaceOp":"append"} -{"type":"step/end","seq":57,"time":1785487621377,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":58,"time":1785487621385,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} -{"type":"assistant/chunk","seq":61,"time":1785036891804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} -{"type":"assistant/chunk","seq":62,"time":1785464659324,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":63,"time":1785487621391,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":1785487621392,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ed857166-2e7d-4153-807f-d321a87c043e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487621392,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":66,"time":1785487621392,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498801734,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"}]}} +{"type":"turn/start","seq":1,"time":1785821417918,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"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":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"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":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498801774,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":1785730458439,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730458440,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0713b7ec-0182-4820-8ec1-39d0371b533b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730458440,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":16,"time":1785730458450,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"b10e76f0-e1a5-4c2c-b6a2-6cbdcf259cca"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458450,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730458460,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":21,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498801800,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":1785730458465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730458465,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3061430-3f2d-4dd8-a3ee-c0fde800547d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730458465,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":28,"time":1785730458520,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"4dce223d-0097-4ac2-a717-d1c430240cef"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1785730458520,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":1785730458527,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1783950000030,"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.\"}"}}} +{"type":"assistant/chunk","seq":33,"time":1785036891179,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":34,"time":1785498801872,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":35,"time":1785730458531,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":1785730458531,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"51fe1d59-eebc-457b-a072-fe217546ff04"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":1785730458531,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":38,"time":1785730458562,"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":"09028579-5ae5-4d57-955e-02504f4dfc2a"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":1785730458563,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":40,"time":1785730458572,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":1783957884594,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"assistant/chunk","seq":43,"time":1785036891211,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"assistant/chunk","seq":44,"time":1785498801920,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":45,"time":1785730458577,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":46,"time":1785730458577,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ebeca5c6-68ae-43b3-87c3-c48fdfe416c8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"tool/call","seq":47,"time":1785730458577,"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-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool/result","seq":48,"time":1785730458711,"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-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f892f17e-1e93-4f4b-9e9e-15116593b6fc"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":1785730458711,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":50,"time":1785730458723,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":53,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":1785498802087,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":55,"time":1785730458728,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":1785730458728,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1291ce3c-e568-4f0d-a95a-5157b8b2cc75"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":1785730458728,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":58,"time":1785730458735,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"b3634221-2358-4e82-aac5-e37f0a115023"}},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1785730458735,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":60,"time":1785730458747,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":62,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":63,"time":1785036891804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":64,"time":1785498802107,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":65,"time":1785730458751,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":66,"time":1785730458751,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a32b89ce-13ed-48ba-a7f9-24144b94ec56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[61,62,63,64,65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730458751,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":68,"time":1785730458751,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index fa8c2ce631..449db5acb4 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,26 +1,28 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"955895f6-6350-4083-b030-0f0ac825ad96"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464634096,"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"},"role":"user","id":"0ad16754-a62f-4241-bfb3-3b92b15970b5"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464634096,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464634097,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487582288,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464634106,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487582296,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487582296,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d1461422-3690-4741-a3e8-3e3625cba659"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487582297,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":14,"time":1785487582361,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_spill"},"content":[{"type":"tool-result","toolCallId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5e53dc8acfe4/2ce9d7a31a38-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false}],"role":"user","id":"c760b7a9-3f87-4560-9277-a4eb3b479193"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487582361,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487582370,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464634160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1785487582375,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785487582375,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6eb99c1f-42ef-42a1-87d4-5b9a445cba98"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785487582375,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785487582376,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498767644,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"4f33bd12-21b5-4ccc-bbd2-4edb0ab6b33b"}]}} +{"type":"turn/start","seq":1,"time":1785821368742,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821368742,"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":1785498767672,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"4f33bd12-21b5-4ccc-bbd2-4edb0ab6b33b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730421018,"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":"ac1209c1-ce77-4622-a7c4-b39225fda7ab"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730421018,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498767673,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730421019,"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":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498767682,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730421028,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730421028,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0f836022-e1b6-4a44-9f49-5472f824fbc9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730421028,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} +{"type":"tool/result","seq":16,"time":1785730421070,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_spill"},"content":[{"type":"tool-result","toolCallId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5e53dc8acfe4/2ce9d7a31a38-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false}],"role":"user","id":"4f751bc4-b81f-4045-b86a-407a4bd08bbe"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730421070,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730421081,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498767739,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1785730421085,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730421085,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"64dccb5c-e621-47f1-af30-04dc7f4ba59d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730421086,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730421086,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl index aeb01cbcc6..6e4de13d80 100644 --- a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"ff9cae4a-f807-487e-87a2-1db21e2d38fa"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352050753,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464636803,"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"},"role":"user","id":"96251611-d8e7-4a7c-808a-91937db1c44a"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464636803,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464636803,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487586468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":26,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":27,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}} -{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":59,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} -{"type":"assistant/chunk","seq":60,"time":1785464636815,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":61,"time":1785487586478,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":62,"time":1785487586478,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9685d5e4-5453-4ebd-9db7-36a402199401"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} -{"type":"tool/call","seq":63,"time":1785487586478,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} -{"type":"tool/result","seq":64,"time":1785487586497,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"73354435-9200-445f-8edd-107f989cb6bb"}},"sourceEventSeqs":[63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487586497,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":66,"time":1785487586505,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":67,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":68,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":90,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":95,"time":1785464636847,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":96,"time":1785487586512,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":97,"time":1785487586512,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4081d4c9-40fa-4a82-bc0b-ce23a3d1b346"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"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,96],"surfaceOp":"append"} -{"type":"step/end","seq":98,"time":1785487586512,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":99,"time":1785487586512,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498771334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"}]}} +{"type":"turn/start","seq":1,"time":1785821375023,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821375023,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352050755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498771360,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730424635,"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":"80474489-442a-4e98-beef-df6cd1e85870"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730424635,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498771361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730424636,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":29,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}} +{"type":"assistant/chunk","seq":60,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":61,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1785498771373,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":63,"time":1785730424645,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1785730424645,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a855246-fbf6-4f91-87b4-c6f1889effe7"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730424646,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} +{"type":"tool/result","seq":66,"time":1785730424665,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"908ca4f5-efbb-443b-9b07-acbf25edf954"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730424665,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":68,"time":1785730424676,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":69,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":70,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":95,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":96,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":97,"time":1785498771406,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":98,"time":1785730424681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":99,"time":1785730424681,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa705bf0-9b5b-4af3-9763-dbf93c98e4c4"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[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,97,98],"surfaceOp":"append"} +{"type":"step/end","seq":100,"time":1785730424682,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":101,"time":1785730424682,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 84ad554779..a1b26a5af4 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785014504349,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785014504350,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"},"role":"user","id":"859496c9-7913-4c61-839c-9d6d4728c640"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785014504359,"data":{"title":"Call the run_code tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464675435,"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"},"role":"user","id":"eda75a65-f377-47bd-8dc7-c15d41d583de"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464675435,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464675435,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487645909,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785014505594,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1785014505633,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,1,0,46,1,0,0,0,1,36,0,0,0,1,0,41,0,0,0,1,0,40,0,0,1,0,0,41,0,0,126,1],"texts":["The"," user"," wants"," me"," to"," call"," the"," run","_code"," tool"," with"," a"," Type","Script"," program"," that"," runs"," `","echo"," B","OTH","_OK","`"," via"," `","tools",".b","ash","`"," and"," returns"," its"," output","."]}} -{"type":"assistant/chunk","seq":42,"time":1785014506012,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":43,"time0":1785014506013,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,41,1,0,40,0,1,0,0,0,42,1,0,0,0,1,40,0,0,1,0,0,42,0,1,0,0,40,1,0,0,42,0,43,1,0,0,0,40,1,0,0,42,0,1,0,43,0,0,41,46,0],"id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","args":["","{","\"","code","\"",": ","\"","const"," result"," ="," await"," tools",".b","ash","({"," command",":"," \\\"","echo"," B","OTH","_OK","\\\","," description",":"," \\\"","Print"," B","OTH","_OK","\\\""," });\\n","return"," result",".stdout",".text",";","\"",", ","\"","description","\"",": ","\"","Run"," echo"," B","OTH","_OK"," via"," tools",".b","ash","\"","}"]}} -{"type":"assistant/chunk","seq":98,"time":1785014506565,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."}}}} -{"type":"assistant/chunk","seq":99,"time":1785014506565,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}}}} -{"type":"assistant/chunk","seq":100,"time":1785464675448,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":101,"time":1785487645921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":102,"time":1785487645921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b36f8ce9-ca65-4f41-822e-f68bba7a0efd"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101],"surfaceOp":"append"} -{"type":"tool/call","seq":103,"time":1785487645922,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}} -{"type":"tool/code-dispatch-start","seq":104,"time":1785487645986,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} -{"type":"tool/code-dispatch","seq":105,"time":1785487645996,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} -{"type":"tool/result","seq":106,"time":1785487645999,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"afcbe904-6181-430f-8fc7-af1549f38c0f"}},"sourceEventSeqs":[103],"surfaceOp":"append"} -{"type":"step/end","seq":107,"time":1785487645999,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":108,"time":1785487646007,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":109,"time":1785014507359,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":110,"time0":1785014507404,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,1,0,41,80,0,0,0,4,0,41,0,0,42,0,0,43,0,0,1,41,0,0,42,0,1,0,0],"texts":["The"," output"," is"," \"","B","OTH","_OK","\""," (","with"," a"," trailing"," new","line",","," but"," that","'s"," fine",")."," The"," user"," asked"," me"," to"," reply"," with"," that"," output"," only","."]}} -{"type":"assistant/chunk","seq":141,"time":1785014507741,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":142,"time0":1785014507784,"data":{"turn":1,"step":2,"index":1,"dt":[1,0],"texts":["B","OTH","_OK"]}} -{"type":"assistant/chunk","seq":145,"time":1785014507785,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."}}}} -{"type":"assistant/chunk","seq":146,"time":1785014507785,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} -{"type":"assistant/chunk","seq":147,"time":1785464675523,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":148,"time":1785487646013,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":149,"time":1785487646013,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."},{"type":"text","text":"BOTH_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ce0dfa69-a91a-450d-86ed-f46d2ba2f548"},"usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}},"sourceEventSeqs":[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,139,140,141,142,143,144,145,146,147,148],"surfaceOp":"append"} -{"type":"step/end","seq":150,"time":1785487646013,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":151,"time":1785487646013,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498827086,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"},"role":"user","id":"922e078d-9ef7-4017-9c4e-96a34a721503"}]}} +{"type":"turn/start","seq":1,"time":1785821443048,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821443048,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785014504370,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498827112,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"},"role":"user","id":"922e078d-9ef7-4017-9c4e-96a34a721503"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730479344,"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":"d3891fd4-21eb-4869-8a66-498764450bf2"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730479344,"data":{"title":"Call the run_code tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498827116,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730479345,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785014505594,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1785014505633,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,1,0,46,1,0,0,0,1,36,0,0,0,1,0,41,0,0,0,1,0,40,0,0,1,0,0,41,0,0,126,1],"texts":["The"," user"," wants"," me"," to"," call"," the"," run","_code"," tool"," with"," a"," Type","Script"," program"," that"," runs"," `","echo"," B","OTH","_OK","`"," via"," `","tools",".b","ash","`"," and"," returns"," its"," output","."]}} +{"type":"assistant/chunk","seq":44,"time":1785014506012,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":45,"time0":1785014506013,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,41,1,0,40,0,1,0,0,0,42,1,0,0,0,1,40,0,0,1,0,0,42,0,1,0,0,40,1,0,0,42,0,43,1,0,0,0,40,1,0,0,42,0,1,0,43,0,0,41,46,0],"id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","args":["","{","\"","code","\"",": ","\"","const"," result"," ="," await"," tools",".b","ash","({"," command",":"," \\\"","echo"," B","OTH","_OK","\\\","," description",":"," \\\"","Print"," B","OTH","_OK","\\\""," });\\n","return"," result",".stdout",".text",";","\"",", ","\"","description","\"",": ","\"","Run"," echo"," B","OTH","_OK"," via"," tools",".b","ash","\"","}"]}} +{"type":"assistant/chunk","seq":100,"time":1785014506565,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."}}}} +{"type":"assistant/chunk","seq":101,"time":1785014506565,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}}}} +{"type":"assistant/chunk","seq":102,"time":1785498827129,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":103,"time":1785730479356,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":104,"time":1785730479356,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1cf31c-fd73-42fc-805d-a14d91228bd9"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[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,97,98,99,100,101,102,103],"surfaceOp":"append"} +{"type":"tool/call","seq":105,"time":1785730479356,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}} +{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} +{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} +{"type":"tool/result","seq":108,"time":1785730479423,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"028e19dd-dcfc-4a67-a6e4-c9fa19716ea3"}},"sourceEventSeqs":[105],"surfaceOp":"append"} +{"type":"step/end","seq":109,"time":1785730479423,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":110,"time":1785730479431,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":111,"time":1785014507359,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":112,"time0":1785014507404,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,1,0,41,80,0,0,0,4,0,41,0,0,42,0,0,43,0,0,1,41,0,0,42,0,1,0,0],"texts":["The"," output"," is"," \"","B","OTH","_OK","\""," (","with"," a"," trailing"," new","line",","," but"," that","'s"," fine",")."," The"," user"," asked"," me"," to"," reply"," with"," that"," output"," only","."]}} +{"type":"assistant/chunk","seq":143,"time":1785014507741,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":144,"time0":1785014507784,"data":{"turn":1,"step":2,"index":1,"dt":[1,0],"texts":["B","OTH","_OK"]}} +{"type":"assistant/chunk","seq":147,"time":1785014507785,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."}}}} +{"type":"assistant/chunk","seq":148,"time":1785014507785,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} +{"type":"assistant/chunk","seq":149,"time":1785498827217,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":150,"time":1785730479437,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":151,"time":1785730479437,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."},{"type":"text","text":"BOTH_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dbd0a9c9-1f19-405d-ad05-86f90447e006"},"usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}},"sourceEventSeqs":[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,139,140,141,142,143,144,145,146,147,148,149,150],"surfaceOp":"append"} +{"type":"step/end","seq":152,"time":1785730479437,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":153,"time":1785730479437,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 5fe449ae48..a3676dbc3d 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -1,23 +1,25 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"},"role":"user","id":"f6cbfd65-b797-44f2-a069-88fade8be54c"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784437195072,"data":{"title":"Run two shell commands: wait","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464652437,"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"},"role":"user","id":"6e19a845-6916-4a14-bbeb-3ec64dc8aa58"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464652437,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464652437,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487611310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785464652446,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} -{"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} -{"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":21,"time":1785487611378,"data":{"turn":1,"reason":{"kind":"aborted"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498792491,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"},"role":"user","id":"6025dc7c-dc38-4a34-b7b1-688102631c75"}]}} +{"type":"turn/start","seq":1,"time":1785821402705,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821402705,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784437195076,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498792518,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"},"role":"user","id":"6025dc7c-dc38-4a34-b7b1-688102631c75"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730445587,"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":"bf953438-d1c4-4e00-a06b-7f5e2da1df7a"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730445587,"data":{"title":"Run two shell commands: wait","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498792519,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730445588,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} +{"type":"assistant/chunk","seq":14,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1785498792528,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} +{"type":"assistant/chunk","seq":16,"time":1785730445597,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1785730445597,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57600715-8366-4277-9cb3-3b6f55fef1ec"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[9,10,11,12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1785730445598,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"tool/result","seq":19,"time":1785730445645,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"f8706456-630a-419b-83b6-91a9f7e464d7"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1785730445645,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} +{"type":"tool/result","seq":21,"time":1785730445645,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"55c65cec-41ad-4361-bc86-e82b7726d445"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":1785730445645,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":23,"time":1785730445645,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 2ec519f9c4..c6396be679 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1,12 +1,14 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"},"role":"user","id":"1d142a0f-dcce-4d62-8e77-110e88d5e33d"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Start a long task; this","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464651681,"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"},"role":"user","id":"964bf863-c389-429a-8404-a1e84d63ea8c"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464651681,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464651682,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487610044,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785464651691,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":8,"time":1785487610053,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":9,"time":1785487610062,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":10,"time":1785487610062,"data":{"turn":1,"reason":{"kind":"aborted"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498791421,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"},"role":"user","id":"f74653c2-8793-4004-ab0d-833a8dfd42bf"}]}} +{"type":"turn/start","seq":1,"time":1785821401560,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821401560,"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":1785498791446,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"},"role":"user","id":"f74653c2-8793-4004-ab0d-833a8dfd42bf"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730444531,"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":"2c4c8dc2-5141-4963-adbc-5928729d3bf6"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730444531,"data":{"title":"Start a long task; this","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498791447,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} +{"type":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 0824fbd0a1..24e1525c79 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,36 +1,38 @@ {"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785014439576,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785014439577,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"34b277a7-1f83-4f80-a484-2d9108716444"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785014439584,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464673719,"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"},"role":"user","id":"c5da8ff4-6491-48c8-9a67-aba9170910ce"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464673719,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464673720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487643197,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785014441049,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1785014441092,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,42,1,0,1,39,1,0,0,0,1,42,0,0,42,0,0,41,0,0,1,0,0,42,0,0,1,0,0,40,1,42,0,45,1,0,0,0,0,39,0,42,0,0,0,1,0,41,0,0,0,0,1,41,1,128,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," run","_code"," program"," that",":\n","1","."," Calls"," bash"," tool"," twice",":"," `","echo"," CODE","_","ONE","`"," and"," `","echo"," CODE","_T","WO","`\n","2","."," console",".log"," exactly"," `","capt","ured"," output","`\n","3","."," Return"," the"," two"," outputs"," joined"," with"," a"," plus"," sign","\n\n","Let"," me"," write"," this","."]}} -{"type":"assistant/chunk","seq":68,"time":1785014441771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":69,"time0":1785014441771,"data":{"turn":1,"step":1,"index":1,"dt":[41,0,1,0,0,41,1,41,1,0,0,0,42,1,40,42,1,0,0,0,0,41,1,0,0,0,41,1,0,44,0,0,1,0,0,39,0,0,0,0,0,45,0,0,0,1,0,38,1,0,0,0,0,42,0,0,0,0,2,40,0,0,0,0,1,40,0,42,1,0,0,0,40,1,0,0,0,0,42,0,1,0,0,0,40,0,0,1,0,41,0,0,0,0,43,44,0,0,0,0,40,1,0,41,43,0,0,41,42,1,88,0],"id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," out","1"," ="," await"," tools",".b","ash","({","command",":"," \\\"","echo"," CODE","_","ONE","\\\","," description",":"," \\\"","Print"," CODE","_","ONE","\\\"","});\\n","const"," out","2"," ="," await"," tools",".b","ash","({","command",":"," \\\"","echo"," CODE","_T","WO","\\\","," description",":"," \\\"","Print"," CODE","_T","WO","\\\"","});\\n","console",".log","(\\\"","capt","ured"," output","\\\");\\n","const"," text","1"," ="," out","1",".stdout",".text",".trim","();\\n","const"," text","2"," ="," out","2",".stdout",".text",".trim","();\\n","return"," text","1"," +"," \\\"+","\\\""," +"," text","2",";\\n","\"",", ","\"","description","\"",": ","\"","Run"," two"," echo"," commands"," and"," join"," outputs","\"","}"]}} -{"type":"assistant/chunk","seq":182,"time":1785014442994,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."}}}} -{"type":"assistant/chunk","seq":183,"time":1785014442995,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}}}} -{"type":"assistant/chunk","seq":184,"time":1785464673735,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}}}} -{"type":"assistant/chunk","seq":185,"time":1785487643212,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":186,"time":1785487643212,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"83c557ea-8fcf-4334-a931-60fb99866ca2"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185],"surfaceOp":"append"} -{"type":"tool/call","seq":187,"time":1785487643212,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}} -{"type":"tool/code-dispatch-start","seq":188,"time":1785487643275,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} -{"type":"tool/code-dispatch","seq":189,"time":1785487643286,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} -{"type":"tool/code-dispatch-start","seq":190,"time":1785487643286,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} -{"type":"tool/code-dispatch","seq":191,"time":1785487643289,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} -{"type":"tool/result","seq":192,"time":1785487643290,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"aa44b25e-71e2-4458-a0d2-9b649eb2aa77"}},"sourceEventSeqs":[187],"surfaceOp":"append"} -{"type":"step/end","seq":193,"time":1785487643291,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":194,"time":1785487643297,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":195,"time":1785014443887,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":196,"time0":1785014443930,"data":{"turn":1,"step":2,"index":0,"dt":[40,0,1,0,41,0,42,1,0,0,0,0,41,0,1,0,44,1,38,0,0,0,0,1,43,0,0,0,1,0,39,0,0,0,1,41,1,0,0,42,0],"texts":["The"," program"," ran"," successfully","."," The"," console",".log"," output"," \"","capt","ured"," output","\""," appeared",","," and"," the"," return"," value"," is"," \"","CODE","_","ONE","+","CODE","_T","WO","\"."," The"," user"," asked"," me"," to"," reply"," with"," that"," joined"," string"," only","."]}} -{"type":"assistant/chunk","seq":238,"time":1785014444349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":239,"time0":1785014444349,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1,41,1,0],"texts":["CODE","_","ONE","+","CODE","_T","WO"]}} -{"type":"assistant/chunk","seq":246,"time":1785014444392,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. The console.log output \"captured output\" appeared, and the return value is \"CODE_ONE+CODE_TWO\". The user asked me to reply with that joined string only."}}}} -{"type":"assistant/chunk","seq":247,"time":1785014444392,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":248,"time":1785464673815,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}}}} -{"type":"assistant/chunk","seq":249,"time":1785487643304,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":250,"time":1785487643304,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. The console.log output \"captured output\" appeared, and the return value is \"CODE_ONE+CODE_TWO\". The user asked me to reply with that joined string only."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"27e0b47a-0d4a-43d2-93c9-0d31ab345ddd"},"usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}},"sourceEventSeqs":[195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"} -{"type":"step/end","seq":251,"time":1785487643304,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":252,"time":1785487643304,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498824594,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"8e2d7086-925a-4734-ba89-418940b0ee58"}]}} +{"type":"turn/start","seq":1,"time":1785821440493,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821440493,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785014439593,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498824620,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"8e2d7086-925a-4734-ba89-418940b0ee58"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730477066,"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":"ea97a8e4-de78-4638-b80a-c24dfeaba555"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730477066,"data":{"title":"Using ONE run_code program: call","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498824624,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730477067,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785014441049,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1785014441092,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,42,1,0,1,39,1,0,0,0,1,42,0,0,42,0,0,41,0,0,1,0,0,42,0,0,1,0,0,40,1,42,0,45,1,0,0,0,0,39,0,42,0,0,0,1,0,41,0,0,0,0,1,41,1,128,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," run","_code"," program"," that",":\n","1","."," Calls"," bash"," tool"," twice",":"," `","echo"," CODE","_","ONE","`"," and"," `","echo"," CODE","_T","WO","`\n","2","."," console",".log"," exactly"," `","capt","ured"," output","`\n","3","."," Return"," the"," two"," outputs"," joined"," with"," a"," plus"," sign","\n\n","Let"," me"," write"," this","."]}} +{"type":"assistant/chunk","seq":70,"time":1785014441771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":71,"time0":1785014441771,"data":{"turn":1,"step":1,"index":1,"dt":[41,0,1,0,0,41,1,41,1,0,0,0,42,1,40,42,1,0,0,0,0,41,1,0,0,0,41,1,0,44,0,0,1,0,0,39,0,0,0,0,0,45,0,0,0,1,0,38,1,0,0,0,0,42,0,0,0,0,2,40,0,0,0,0,1,40,0,42,1,0,0,0,40,1,0,0,0,0,42,0,1,0,0,0,40,0,0,1,0,41,0,0,0,0,43,44,0,0,0,0,40,1,0,41,43,0,0,41,42,1,88,0],"id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," out","1"," ="," await"," tools",".b","ash","({","command",":"," \\\"","echo"," CODE","_","ONE","\\\","," description",":"," \\\"","Print"," CODE","_","ONE","\\\"","});\\n","const"," out","2"," ="," await"," tools",".b","ash","({","command",":"," \\\"","echo"," CODE","_T","WO","\\\","," description",":"," \\\"","Print"," CODE","_T","WO","\\\"","});\\n","console",".log","(\\\"","capt","ured"," output","\\\");\\n","const"," text","1"," ="," out","1",".stdout",".text",".trim","();\\n","const"," text","2"," ="," out","2",".stdout",".text",".trim","();\\n","return"," text","1"," +"," \\\"+","\\\""," +"," text","2",";\\n","\"",", ","\"","description","\"",": ","\"","Run"," two"," echo"," commands"," and"," join"," outputs","\"","}"]}} +{"type":"assistant/chunk","seq":184,"time":1785014442994,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."}}}} +{"type":"assistant/chunk","seq":185,"time":1785014442995,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}}}} +{"type":"assistant/chunk","seq":186,"time":1785498824638,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}}}} +{"type":"assistant/chunk","seq":187,"time":1785730477079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":188,"time":1785730477079,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59e638d7-2aa2-48a2-ae0e-5833b1152ce6"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187],"surfaceOp":"append"} +{"type":"tool/call","seq":189,"time":1785730477080,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}} +{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} +{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} +{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} +{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} +{"type":"tool/result","seq":194,"time":1785730477150,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"e40c6472-d68e-4be1-963f-edb0edc80d82"}},"sourceEventSeqs":[189],"surfaceOp":"append"} +{"type":"step/end","seq":195,"time":1785730477150,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":196,"time":1785730477158,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":197,"time":1785014443887,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":198,"time0":1785014443930,"data":{"turn":1,"step":2,"index":0,"dt":[40,0,1,0,41,0,42,1,0,0,0,0,41,0,1,0,44,1,38,0,0,0,0,1,43,0,0,0,1,0,39,0,0,0,1,41,1,0,0,42,0],"texts":["The"," program"," ran"," successfully","."," The"," console",".log"," output"," \"","capt","ured"," output","\""," appeared",","," and"," the"," return"," value"," is"," \"","CODE","_","ONE","+","CODE","_T","WO","\"."," The"," user"," asked"," me"," to"," reply"," with"," that"," joined"," string"," only","."]}} +{"type":"assistant/chunk","seq":240,"time":1785014444349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":241,"time0":1785014444349,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1,41,1,0],"texts":["CODE","_","ONE","+","CODE","_T","WO"]}} +{"type":"assistant/chunk","seq":248,"time":1785014444392,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. The console.log output \"captured output\" appeared, and the return value is \"CODE_ONE+CODE_TWO\". The user asked me to reply with that joined string only."}}}} +{"type":"assistant/chunk","seq":249,"time":1785014444392,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":250,"time":1785498824729,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}}}} +{"type":"assistant/chunk","seq":251,"time":1785730477165,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":252,"time":1785730477165,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. The console.log output \"captured output\" appeared, and the return value is \"CODE_ONE+CODE_TWO\". The user asked me to reply with that joined string only."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e435b807-b35f-48d3-846f-a5c59333c316"},"usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}},"sourceEventSeqs":[197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251],"surfaceOp":"append"} +{"type":"step/end","seq":253,"time":1785730477165,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":254,"time":1785730477165,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/replay.override.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/replay.override.json new file mode 100644 index 0000000000..192a2bdcac --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_workspace_read", "name": "run_code", "argumentsDelta": "{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_workspace_read", "name": "run_code", "arguments": "{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 4588ebb898..6107589cb2 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,36 +1,34 @@ {"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785122256262,"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"},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"<system-reminder>\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\nWorkspace snapshot root instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1785014475638,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1785014475639,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,40,1,0,0,0,43,0,0,0,39,43,1,0,0,0,40,1,40,0,0,1,42,0,1,0,0,40,0,0,1,0,0,44,0,1,39,0,1,0,126,0,41],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," `","n","ested","/t","ask",".txt","`"," using"," a"," `","run","_code","`"," program",","," and"," then"," answer"," the"," question"," \"","What"," is"," the"," Code"," Mode"," workspace"," hand","shake","?\""," based"," on"," the"," contents"," of"," that"," file","."]}} -{"type":"assistant/chunk","seq":55,"time":1785014476225,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":56,"time0":1785014476225,"data":{"turn":1,"step":1,"index":1,"dt":[0,41,0,0,1,41,1,0,0,40,0,42,0,0,0,1,0,40,1,0,0,0,0,41,0,0,42,1,0,41,1,0,0,42,0,0,0,0,41,89,1,0],"id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," result"," ="," await"," tools",".read","({"," file","_path",":"," \\\"","n","ested","/t","ask",".txt","\\\""," });\\n","return"," result",";\\n","\"",", ","\"","description","\"",": ","\"","Read"," nested","/t","ask",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":99,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."}}}} -{"type":"assistant/chunk","seq":100,"time":1785122256269,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":101,"time":1785464674595,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}}}} -{"type":"assistant/chunk","seq":102,"time":1785487644567,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":103,"time":1785487644567,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dcdb77e2-9d85-457e-a831-c53fa8c81bc9"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"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,97,98,99,100,101,102],"surfaceOp":"append"} -{"type":"tool/call","seq":104,"time":1785487644568,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":105,"time":1785487644629,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":106,"time":1785487644632,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}]}} -{"type":"tool/result","seq":107,"time":1785487644634,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"51eadd14-c38c-4ca2-ba35-f5e0d76a31f6"}},"sourceEventSeqs":[104],"surfaceOp":"append"} -{"type":"user/message","seq":108,"time":1785487644634,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"6e280d70-19bc-41fd-91f1-2f03885114c6"},"surfaceOp":"append"} -{"type":"step/end","seq":109,"time":1785487644635,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":110,"time":1785487644641,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":111,"time":1785014477475,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":112,"time0":1785014477476,"data":{"turn":1,"step":2,"index":0,"dt":[0,26,0,0,42,0,43,1,42,1,0,0,0,0,42,0,0,0,1,0,40,0,0,1,0,0,43,41,0,43],"texts":["The"," nested","/","AG","ENTS",".md"," file"," provides"," the"," instruction",":"," when"," asked"," for"," the"," Code"," Mode"," workspace"," hand","shake",","," answer"," exactly"," `","CODE","_M","ODE","_CONT","EXT","_OK","`."]}} -{"type":"assistant/chunk","seq":143,"time":1785014477882,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":144,"time0":1785014477882,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1,42,0,0,1,0,0,41,0,0,0,1],"texts":["**","Code"," Mode"," workspace"," hand","shake",":**"," `","CODE","_M","ODE","_CONT","EXT","_OK","`"]}} -{"type":"assistant/chunk","seq":159,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."}}}} -{"type":"assistant/chunk","seq":160,"time":1785122256351,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} -{"type":"assistant/chunk","seq":161,"time":1785464674660,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":162,"time":1785487644644,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":163,"time":1785487644644,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"77786748-ff8f-4793-acad-b15630137af0"},"usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}},"sourceEventSeqs":[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,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"} -{"type":"step/end","seq":164,"time":1785487644644,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":165,"time":1785487644644,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498825884,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"3b04578e-7b22-4b44-b4cd-ef9d4d26fe8b"}]}} +{"type":"turn/start","seq":1,"time":1785821441771,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785498825916,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785901435161,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498825917,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"3b04578e-7b22-4b44-b4cd-ef9d4d26fe8b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785901435161,"data":{"content":[{"type":"text","text":"<system-reminder>\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\nWorkspace snapshot root instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"ac92e76e-4861-47a6-87f8-4e9ca904eb24"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730478198,"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":"d6d78330-05c0-4ebd-9e29-595df6440250"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730478198,"data":{"title":"Using ONE run_code program, call","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498825920,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730478199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1785014475638,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":12,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1785733131056,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9402d85-58bd-4881-b890-0b186f661671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1785733131056,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} +{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}]}} +{"type":"tool/result","seq":19,"time":1785733131112,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"bde1c12e-44d1-44f7-ba7e-868349ed2b05"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785733131112,"data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","seq":21,"time":1785733131112,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"}]}} +{"type":"agent/inbox/spliced","seq":22,"time":1785733131116,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"step/start","seq":23,"time":1785733131123,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":24,"time":1785733131123,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":25,"time":1785014475805,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1785014475806,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}} +{"type":"assistant/chunk","seq":27,"time":1785901435233,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":28,"time":1785901435233,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":1785901435233,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1785901435233,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"add632ac-e646-4e50-84d3-96a084427a01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1785901435233,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":32,"time":1785901435234,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 2bbe6dd86b..12a88365a6 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,36 +1,38 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"e4b57451-5b47-4e0e-8a92-d45b37e114e5"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784449176718,"data":{"title":"Inspect the exact tools service","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464660111,"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"},"role":"user","id":"2a0e707e-4fd1-4e60-b610-18518bb8011b"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464660111,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464660112,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487622694,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-api","name":"cordis_inspect","argumentsDelta":"{\"what\":\"api\",\"name\":\"tools\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464660123,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise<SteeringOutcome>;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-event","name":"cordis_inspect","argumentsDelta":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}} -{"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464660161,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":21,"time":1785487622739,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487622739,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1563ef5e-3ff8-4d11-8845-90305f229b62"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487622740,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} -{"type":"tool/result","seq":24,"time":1785487622747,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-tools-event"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false}],"role":"user","id":"c8c1b6f3-501a-40e4-8232-9e48b3d110a0"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785487622747,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785487622755,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":28,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}}} -{"type":"assistant/chunk","seq":29,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} -{"type":"assistant/chunk","seq":30,"time":1785464660183,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":31,"time":1785487622760,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785487622760,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"15e94d18-f791-462a-9f64-d6342afca4ec"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785487622761,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":34,"time":1785487622761,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498803392,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"3a6e7222-9340-429e-bec7-c30fcd063c70"}]}} +{"type":"turn/start","seq":1,"time":1785821419616,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821419616,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784449176720,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498803419,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"3a6e7222-9340-429e-bec7-c30fcd063c70"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730459873,"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":"f9a387d6-bd6f-4613-9c11-5768017feb5c"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730459873,"data":{"title":"Inspect the exact tools service","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498803423,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730459874,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-api","name":"cordis_inspect","argumentsDelta":"{\"what\":\"api\",\"name\":\"tools\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498803432,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-event","name":"cordis_inspect","argumentsDelta":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}} +{"type":"assistant/chunk","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498803470,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":1785730459921,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730459921,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"283c82b3-1bda-481c-a716-c35f363c9752"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730459921,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} +{"type":"tool/result","seq":26,"time":1785730459929,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-tools-event"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false}],"role":"user","id":"c187306a-d73c-4bcd-b76c-8607ddbc0974"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730459929,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1785730459939,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}}} +{"type":"assistant/chunk","seq":31,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"type":"assistant/chunk","seq":32,"time":1785498803491,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":33,"time":1785730459943,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785730459943,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a4aa43b5-240e-423a-bc03-0abed8d890e4"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730459943,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":36,"time":1785730459943,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index 6f24b8cacf..b619d352e5 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -1,23 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"c94e1add-a8cb-4c8d-b987-6471f5569f7a"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464649169,"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"},"role":"user","id":"13ccc421-f59c-44cb-8257-fc293353e56f"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464649169,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464649170,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487606161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785464649179,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} -{"type":"assistant/chunk","seq":8,"time":1785487606169,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} -{"type":"step/end","seq":9,"time":1785487606169,"data":{"turn":1,"step":1}} -{"type":"llm/retry","seq":10,"time":1785487606169,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"turn/end","seq":11,"time":1785487606171,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}} -{"type":"turn/start","seq":12,"time":1785487606176,"data":{"turn":2,"trigger":{"kind":"retry"}}} -{"type":"step/start","seq":13,"time":1785487606181,"data":{"turn":2,"step":1}} -{"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":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":16,"time":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":17,"time":1785464649194,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":18,"time":1785487606186,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":19,"time":1785487606186,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"348c5576-4dc1-4931-891f-e86fe044b734"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":1785487606186,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":21,"time":1785487606186,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498788069,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"04a4b0d6-8873-4ec0-bed5-75de910b556f"}]}} +{"type":"turn/start","seq":1,"time":1785821397720,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821397720,"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":1785498788095,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"04a4b0d6-8873-4ec0-bed5-75de910b556f"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730441191,"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":"1bbd9bae-e790-4b83-8425-2f042dd37908"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730441191,"data":{"title":"This prompt first receives an","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498788096,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730441192,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785498788105,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} +{"type":"assistant/chunk","seq":10,"time":1785730441201,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} +{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"assistant/chunk","seq":12,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785730441209,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730441209,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1785730441209,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index 28a3901d79..fdcb57aabd 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1,10 +1,13 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"7b6a38ae-fbcf-4960-8507-f007d3023511"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt triggers a recorded","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464648421,"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"},"role":"user","id":"b32402ce-98ac-4228-bb37-7a8e88ef2cad"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464648421,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464648422,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487604902,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"step/end","seq":7,"time":1785487604910,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":8,"time":1785487604910,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785499006384,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"87677683-56b7-458b-b512-6db73c570e08"}]}} +{"type":"turn/start","seq":1,"time":1785821396359,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821396359,"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":1785499006415,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"87677683-56b7-458b-b512-6db73c570e08"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730686099,"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":"b3b9d048-3992-458f-aad5-b738e4a7d815"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730686099,"data":{"title":"This prompt triggers a recorded","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785499006416,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730686100,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785730686108,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}}}} +{"type":"step/end","seq":10,"time":1785730686108,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1785730686109,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index df0b05f88f..77a4a39b8d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,35 +1,37 @@ {"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784821261714,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"beebaefe-58d1-48a2-80fe-e8108b3a33aa"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784821261714,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464676286,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"1bb48742-b971-44f6-b519-0ddafe9b0b32"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464676286,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464676286,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487647359,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784821261748,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1784821261748,"data":{"turn":1,"step":1,"index":0,"dt":[-960585284,1,0,0,0,34,0,0,23,3,0,0,28,0,1,0,0,29,0,28,28,1,32,1,32,23,59],"texts":["The"," user"," wants"," me"," to"," run"," a"," command"," with"," sand","box","_per","missions"," set"," to"," danger","-full","-access",","," no"," prior"," run"," needed",","," justified"," as"," instructed","."]}} -{"type":"assistant/chunk","seq":36,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":37,"time0":1783860676816,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,29,0,1,0,32,0,0,0,0,1,30,2,1,0,0,27,2,0,0,0,26,2,0,1,0,0,33,0,0,0,1,0,22,0,0,0,0,0,29,30,2,0,0,0,28,0,1,0,30,1,1,26,28,0,0,0,0,36,0,0,0,37,0,1,0,16,1,27,1,0,0,28,0,0,0,0,0,39,0,0,0,104,1,0,0],"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '","es","cal","ated","\\\\n","'"," >"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," cat"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," rm"," /","tmp","/d","sh","-es","cal","ated",".txt","\"",", ","\"","description","\"",": ","\"","Write"," file"," outside"," workspace"," and"," verify","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," write"," a"," file"," outside"," the"," workspace","\"","}"]}} -{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} -{"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":128,"time":1785464676300,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":129,"time":1785487647372,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":130,"time":1785487647372,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a0a715de-89d5-4e16-8171-ed394b25915c"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} -{"type":"tool/call","seq":131,"time":1785487647372,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1785487647380,"data":{"id":"522686b2-f12a-41b7-b6e7-cbb422a9f866","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1785487647382,"data":{"id":"522686b2-f12a-41b7-b6e7-cbb422a9f866","outcome":"allowed-once"}} -{"type":"tool/result","seq":134,"time":1785487647399,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441"},"content":[{"type":"tool-result","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false}],"role":"user","id":"a6bb0ebd-16b1-4d66-93b8-dfa72eb9553e"}},"sourceEventSeqs":[131],"surfaceOp":"append"} -{"type":"step/end","seq":135,"time":1785487647400,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":136,"time":1785487647408,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":137,"time":1784821261788,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":138,"time0":1784821261788,"data":{"turn":1,"step":2,"index":0,"dt":[-960582977,0,22,1,0,34,0,0,36,1,21,0,0,49,1,0,0,0,0,23,2,1,0,0,14,1,0,0,29,1,1,0,31,0,24,33,0,0],"texts":["The"," command"," succeeded"," —"," it"," wrote"," the"," file",","," read"," it"," back"," (","output"," \"","es","cal","ated","\"),"," and"," removed"," it","."," The"," user"," asked"," me"," to"," reply"," with"," the"," single"," word"," D","ONE"," after"," the"," result","."]}} -{"type":"assistant/chunk","seq":177,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":178,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} -{"type":"assistant/chunk","seq":181,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":182,"time":1785464676343,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} -{"type":"assistant/chunk","seq":183,"time":1785487647415,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":184,"time":1785487647415,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a958f0-102f-4671-b780-b650cedcac53"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183],"surfaceOp":"append"} -{"type":"step/end","seq":185,"time":1785487647415,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":186,"time":1785487647415,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498828287,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"c8597dbb-3765-4c91-9315-2a5704ab60de"}]}} +{"type":"turn/start","seq":1,"time":1785821444447,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821444447,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784821261726,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498828313,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"c8597dbb-3765-4c91-9315-2a5704ab60de"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730480503,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"b945fb82-1839-405c-9859-f2d4630a1801"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730480503,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498828315,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730480504,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784821261748,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1784821261748,"data":{"turn":1,"step":1,"index":0,"dt":[-960585284,1,0,0,0,34,0,0,23,3,0,0,28,0,1,0,0,29,0,28,28,1,32,1,32,23,59],"texts":["The"," user"," wants"," me"," to"," run"," a"," command"," with"," sand","box","_per","missions"," set"," to"," danger","-full","-access",","," no"," prior"," run"," needed",","," justified"," as"," instructed","."]}} +{"type":"assistant/chunk","seq":38,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":39,"time0":1783860676816,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,29,0,1,0,32,0,0,0,0,1,30,2,1,0,0,27,2,0,0,0,26,2,0,1,0,0,33,0,0,0,1,0,22,0,0,0,0,0,29,30,2,0,0,0,28,0,1,0,30,1,1,26,28,0,0,0,0,36,0,0,0,37,0,1,0,16,1,27,1,0,0,28,0,0,0,0,0,39,0,0,0,104,1,0,0],"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '","es","cal","ated","\\\\n","'"," >"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," cat"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," rm"," /","tmp","/d","sh","-es","cal","ated",".txt","\"",", ","\"","description","\"",": ","\"","Write"," file"," outside"," workspace"," and"," verify","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," write"," a"," file"," outside"," the"," workspace","\"","}"]}} +{"type":"assistant/chunk","seq":128,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"type":"assistant/chunk","seq":129,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":130,"time":1785498828327,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":131,"time":1785730480516,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":132,"time":1785730480516,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3212ce1c-5e0f-4f11-9daa-47054a39bf28"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131],"surfaceOp":"append"} +{"type":"tool/call","seq":133,"time":1785730480516,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":134,"time":1785730480525,"data":{"id":"7e4e0dfa-6ff0-4037-b519-297a1e7f11cf","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":135,"time":1785730480526,"data":{"id":"7e4e0dfa-6ff0-4037-b519-297a1e7f11cf","outcome":"allowed-once"}} +{"type":"tool/result","seq":136,"time":1785730480541,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441"},"content":[{"type":"tool-result","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false}],"role":"user","id":"00a41fe4-a3a5-4d44-baa6-effdbc2508bc"}},"sourceEventSeqs":[133],"surfaceOp":"append"} +{"type":"step/end","seq":137,"time":1785730480541,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":138,"time":1785730480551,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":139,"time":1784821261788,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":140,"time0":1784821261788,"data":{"turn":1,"step":2,"index":0,"dt":[-960582977,0,22,1,0,34,0,0,36,1,21,0,0,49,1,0,0,0,0,23,2,1,0,0,14,1,0,0,29,1,1,0,31,0,24,33,0,0],"texts":["The"," command"," succeeded"," —"," it"," wrote"," the"," file",","," read"," it"," back"," (","output"," \"","es","cal","ated","\"),"," and"," removed"," it","."," The"," user"," asked"," me"," to"," reply"," with"," the"," single"," word"," D","ONE"," after"," the"," result","."]}} +{"type":"assistant/chunk","seq":179,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":180,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":181,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":182,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"type":"assistant/chunk","seq":183,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":184,"time":1785498828369,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":185,"time":1785730480556,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":186,"time":1785730480556,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"feade984-75a1-44dc-aed5-7cb93736c376"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185],"surfaceOp":"append"} +{"type":"step/end","seq":187,"time":1785730480557,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":188,"time":1785730480557,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 6040dcc2cd..96b51253d9 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784821263241,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"},"role":"user","id":"548616cc-75fa-4e57-a5d9-e16540543b5f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784821263241,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464677100,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"ef0b7f0b-8d33-411e-9c88-44fcb6b8cb99"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464677100,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464677100,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487648853,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784821263288,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1784821263288,"data":{"turn":1,"step":1,"index":0,"dt":[-960582509,3,0,0,48,1,0,28,0,9,3,0,1,0,30,1,0,0,0,0,34,1,0,18,2,0,0,27,0,37,2,0,0,0,19,48,0,0,0,0,0,16,0,1,30,0,113],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," command"," with"," `","sand","box","_per","missions","`"," set"," to"," `","danger","-full","-access","`"," and"," a"," specific"," justification","."," They"," explicitly"," said"," NOT"," to"," run"," it"," without"," sand","box","_per","missions"," first","."," Let"," me"," do"," exactly"," that","."]}} -{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":57,"time0":1783860681251,"data":{"turn":1,"step":1,"index":1,"dt":[0,1,0,0,0,28,1,0,43,0,0,0,0,0,18,1,0,0,25,3,0,0,0,29,0,0,0,1,1,30,0,0,0,0,0,24,0,0,0,0,0,29,34,2,0,0,0,21,3,0,28,0,1,31,3,22,2,29,0,32,0,0,0,32,0,1,0,25,2,1,0,0,59,0,0,0,0,2,25,2,0,0,0,28,0,0,0,0,2,29,2,64],"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '","es","cal","ated","\\\\n","'"," >"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," cat"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," rm"," /","tmp","/d","sh","-es","cal","ated",".txt","\"",", ","\"","description","\"",": ","\"","Write"," to"," /","tmp"," and"," verify",","," then"," clean"," up","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," write"," a"," file"," outside"," the"," workspace","\"","}"]}} -{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":152,"time":1785464677115,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} -{"type":"assistant/chunk","seq":153,"time":1785487648866,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":154,"time":1785487648866,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a005c2c6-32c6-4da0-9005-2e3f309e0032"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} -{"type":"tool/call","seq":155,"time":1785487648867,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1785487648877,"data":{"id":"5909ec38-6431-4e03-b09f-c9b85dfb4c4b","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1785487648878,"data":{"id":"5909ec38-6431-4e03-b09f-c9b85dfb4c4b","outcome":"rejected"}} -{"type":"tool/result","seq":158,"time":1785487648879,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"content":[{"type":"tool-result","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true}],"role":"user","id":"77e4c527-6ceb-4f78-8407-596205b75903"}},"sourceEventSeqs":[155],"surfaceOp":"append"} -{"type":"step/end","seq":159,"time":1785487648879,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":160,"time":1785487648886,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":161,"time":1784821263313,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":162,"time0":1784821263313,"data":{"turn":1,"step":2,"index":0,"dt":[-960580173,1,0,29,0,0,35,3,0,0,0,22,2,1,27,0,0,0,0,34,2,0,21,0,0,1,0,0,29],"texts":["The"," user"," rejected"," the"," escalation","."," As"," instructed",","," I"," will"," not"," ret","ry"," and"," will"," not"," work"," around"," it"," —"," just"," explain"," in"," one"," short"," sentence"," and"," stop","."]}} -{"type":"assistant/chunk","seq":192,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":193,"time0":1783860683349,"data":{"turn":1,"step":2,"index":1,"dt":[0,26,1,33,1,0,25,2,0,25,2,0,42],"texts":["The"," user"," rejected"," the"," permission"," escalation",","," so"," this"," command"," cannot"," be"," run","."]}} -{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":208,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} -{"type":"assistant/chunk","seq":209,"time":1785464677139,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":210,"time":1785487648893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":211,"time":1785487648893,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8f9ea9a3-eca7-4d89-a118-806332546d57"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210],"surfaceOp":"append"} -{"type":"step/end","seq":212,"time":1785487648893,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":213,"time":1785487648893,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498829461,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"},"role":"user","id":"e1326897-4139-437b-959c-3b25e46e60ec"}]}} +{"type":"turn/start","seq":1,"time":1785821445663,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821445663,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784821263267,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498829488,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"},"role":"user","id":"e1326897-4139-437b-959c-3b25e46e60ec"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730481594,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"016923c3-51c4-45ba-8a54-4d9d309c0d8e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730481594,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498829489,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730481595,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784821263288,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1784821263288,"data":{"turn":1,"step":1,"index":0,"dt":[-960582509,3,0,0,48,1,0,28,0,9,3,0,1,0,30,1,0,0,0,0,34,1,0,18,2,0,0,27,0,37,2,0,0,0,19,48,0,0,0,0,0,16,0,1,30,0,113],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," command"," with"," `","sand","box","_per","missions","`"," set"," to"," `","danger","-full","-access","`"," and"," a"," specific"," justification","."," They"," explicitly"," said"," NOT"," to"," run"," it"," without"," sand","box","_per","missions"," first","."," Let"," me"," do"," exactly"," that","."]}} +{"type":"assistant/chunk","seq":58,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":59,"time0":1783860681251,"data":{"turn":1,"step":1,"index":1,"dt":[0,1,0,0,0,28,1,0,43,0,0,0,0,0,18,1,0,0,25,3,0,0,0,29,0,0,0,1,1,30,0,0,0,0,0,24,0,0,0,0,0,29,34,2,0,0,0,21,3,0,28,0,1,31,3,22,2,29,0,32,0,0,0,32,0,1,0,25,2,1,0,0,59,0,0,0,0,2,25,2,0,0,0,28,0,0,0,0,2,29,2,64],"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '","es","cal","ated","\\\\n","'"," >"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," cat"," /","tmp","/d","sh","-es","cal","ated",".txt"," &&"," rm"," /","tmp","/d","sh","-es","cal","ated",".txt","\"",", ","\"","description","\"",": ","\"","Write"," to"," /","tmp"," and"," verify",","," then"," clean"," up","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," write"," a"," file"," outside"," the"," workspace","\"","}"]}} +{"type":"assistant/chunk","seq":152,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":153,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":154,"time":1785498829503,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} +{"type":"assistant/chunk","seq":155,"time":1785730481607,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":156,"time":1785730481607,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b2c56f7e-0cda-4ddf-a049-177231d234e3"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} +{"type":"tool/call","seq":157,"time":1785730481608,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":158,"time":1785730481615,"data":{"id":"15cd5a18-13cf-4b4e-bca2-30937c1cd39a","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":159,"time":1785730481616,"data":{"id":"15cd5a18-13cf-4b4e-bca2-30937c1cd39a","outcome":"rejected"}} +{"type":"tool/result","seq":160,"time":1785730481616,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"content":[{"type":"tool-result","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true}],"role":"user","id":"5391737f-d7a5-4e47-9f89-b77747df6327"}},"sourceEventSeqs":[157],"surfaceOp":"append"} +{"type":"step/end","seq":161,"time":1785730481617,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":162,"time":1785730481624,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":163,"time":1784821263313,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":164,"time0":1784821263313,"data":{"turn":1,"step":2,"index":0,"dt":[-960580173,1,0,29,0,0,35,3,0,0,0,22,2,1,27,0,0,0,0,34,2,0,21,0,0,1,0,0,29],"texts":["The"," user"," rejected"," the"," escalation","."," As"," instructed",","," I"," will"," not"," ret","ry"," and"," will"," not"," work"," around"," it"," —"," just"," explain"," in"," one"," short"," sentence"," and"," stop","."]}} +{"type":"assistant/chunk","seq":194,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":195,"time0":1783860683349,"data":{"turn":1,"step":2,"index":1,"dt":[0,26,1,33,1,0,25,2,0,25,2,0,42],"texts":["The"," user"," rejected"," the"," permission"," escalation",","," so"," this"," command"," cannot"," be"," run","."]}} +{"type":"assistant/chunk","seq":209,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"type":"assistant/chunk","seq":210,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"type":"assistant/chunk","seq":211,"time":1785498829525,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":212,"time":1785730481628,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":213,"time":1785730481628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"780bcab9-e903-46c1-befa-a72b6cf93dcb"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212],"surfaceOp":"append"} +{"type":"step/end","seq":214,"time":1785730481629,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":215,"time":1785730481629,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index d653b6efc4..cfcdfee68c 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,46 +1,48 @@ {"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352084740,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352084740,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"2ab0e6c7-28f9-4821-88b7-a161f0333efc"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352084740,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464644190,"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"},"role":"user","id":"7202538e-bcdd-4460-a9f0-952e37d70171"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464644190,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464644191,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487598384,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352085563,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352085592,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,0,1,27,0,0,1,0,0,27,1,28,0,0,0,0,1,40,0,1,0,0,0,16,1,27,0,0,0,0,1,32,0,0,1,31,1,52,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," config",".txt"," in"," the"," current"," directory","\n","2","."," Use"," the"," edit"," tool"," to"," replace"," DEBUG"," with"," RE","LEASE","\n","3","."," Reply"," with"," exactly"," \"","D","ONE","\"\n\n","Let"," me"," start"," by"," reading"," the"," file","."]}} -{"type":"assistant/chunk","seq":54,"time":1783352085938,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":55,"time0":1783352085939,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,1,0,27,0,0,31,31,0],"id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","args":["","{","\"","file","_path","\"",": ","\"","config",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":67,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."}}}} -{"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} -{"type":"assistant/chunk","seq":69,"time":1785464644203,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}}}} -{"type":"assistant/chunk","seq":70,"time":1785487598394,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":71,"time":1785487598395,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5509d1ed-ce36-486b-b8d7-09146a3ce435"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70],"surfaceOp":"append"} -{"type":"tool/call","seq":72,"time":1785487598395,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":73,"time":1785487598405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false}],"role":"user","id":"ce5d77a9-0a52-4c8d-9c8e-7f7a7c958da5"},"meta":{"path":"{{cwd}}/config.txt","offset":1,"lines":[{"number":1,"text":"mode=DEBUG"},{"number":2,"text":"level=info"}],"totalLines":2}},"sourceEventSeqs":[72],"surfaceOp":"append"} -{"type":"step/end","seq":74,"time":1785487598405,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":75,"time":1785487598412,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":76,"time":1783352086984,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":77,"time0":1783352087012,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,27,0,1,0,0,27,1,0,0,28,1,0,83,0],"texts":["Now"," I"," need"," to"," replace"," \"","DEBUG","\""," with"," \"","RE","LEASE","\""," using"," the"," edit"," tool","."]}} -{"type":"assistant/chunk","seq":95,"time":1783352087181,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":96,"time0":1783352087181,"data":{"turn":1,"step":2,"index":1,"dt":[28,1,0,0,51,1,0,0,4,0,39,0,0,0,17,0,0,28,0,0,29,0,0,0,28,0,0,31,31,0],"id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","config",".txt","\"",", ","\"","old","_string","\"",": ","\"","DEBUG","\"",", ","\"","new","_string","\"",": ","\"","RE","LEASE","\"","}"]}} -{"type":"assistant/chunk","seq":127,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} -{"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} -{"type":"assistant/chunk","seq":129,"time":1785464644228,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":130,"time":1785487598419,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785487598419,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"773f83bc-1308-4fe2-9931-b86bdedf5465"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785487598419,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":133,"time":1785487598434,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"60c3e7fe-f2b9-4dac-b2c1-c35173274676"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[132],"surfaceOp":"append"} -{"type":"step/end","seq":134,"time":1785487598434,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":135,"time":1785487598441,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":136,"time":1783352088382,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":137,"time0":1783352088408,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,27,29,0,1,0,27,0,0,0,0,1],"texts":["Done","."," The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":151,"time":1783352088522,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":152,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":153,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":154,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. The user wants me to reply with exactly \"DONE\"."}}}} -{"type":"assistant/chunk","seq":155,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":156,"time":1785464644259,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":244,"outputTokens":17,"cacheReadTokens":2944,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":157,"time":1785487598446,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":158,"time":1785487598446,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Done. The user wants me to reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2f3ef853-07c5-45a5-8c20-c6493ad439a8"},"usage":{"inputTokens":244,"outputTokens":17,"cacheReadTokens":2944,"reasoningTokens":14}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157],"surfaceOp":"append"} -{"type":"step/end","seq":159,"time":1785487598446,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":160,"time":1785487598446,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498781465,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"b900992d-cb68-45e3-bdf1-366e2529f6c0"}]}} +{"type":"turn/start","seq":1,"time":1785821389288,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821389289,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352084742,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498781491,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"b900992d-cb68-45e3-bdf1-366e2529f6c0"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730434501,"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":"79d38e8e-c85a-434a-9638-490dea3c8ea8"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730434501,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498781493,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730434502,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352085563,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352085592,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,0,1,27,0,0,1,0,0,27,1,28,0,0,0,0,1,40,0,1,0,0,0,16,1,27,0,0,0,0,1,32,0,0,1,31,1,52,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," config",".txt"," in"," the"," current"," directory","\n","2","."," Use"," the"," edit"," tool"," to"," replace"," DEBUG"," with"," RE","LEASE","\n","3","."," Reply"," with"," exactly"," \"","D","ONE","\"\n\n","Let"," me"," start"," by"," reading"," the"," file","."]}} +{"type":"assistant/chunk","seq":56,"time":1783352085938,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":57,"time0":1783352085939,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,1,0,27,0,0,31,31,0],"id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","args":["","{","\"","file","_path","\"",": ","\"","config",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":69,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."}}}} +{"type":"assistant/chunk","seq":70,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} +{"type":"assistant/chunk","seq":71,"time":1785498781503,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}}}} +{"type":"assistant/chunk","seq":72,"time":1785730434513,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":73,"time":1785730434513,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bdc4fc76-7af7-452a-8b38-7a78997fe1ed"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":74,"time":1785730434513,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} +{"type":"tool/result","seq":75,"time":1785730434523,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false}],"role":"user","id":"391c9198-deef-4c23-9e56-fd7147fd2273"},"meta":{"path":"{{cwd}}/config.txt","offset":1,"lines":[{"number":1,"text":"mode=DEBUG"},{"number":2,"text":"level=info"}],"totalLines":2}},"sourceEventSeqs":[74],"surfaceOp":"append"} +{"type":"step/end","seq":76,"time":1785730434523,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":77,"time":1785730434533,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":78,"time":1783352086984,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":79,"time0":1783352087012,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,27,0,1,0,0,27,1,0,0,28,1,0,83,0],"texts":["Now"," I"," need"," to"," replace"," \"","DEBUG","\""," with"," \"","RE","LEASE","\""," using"," the"," edit"," tool","."]}} +{"type":"assistant/chunk","seq":97,"time":1783352087181,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":98,"time0":1783352087181,"data":{"turn":1,"step":2,"index":1,"dt":[28,1,0,0,51,1,0,0,4,0,39,0,0,0,17,0,0,28,0,0,29,0,0,0,28,0,0,31,31,0],"id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","config",".txt","\"",", ","\"","old","_string","\"",": ","\"","DEBUG","\"",", ","\"","new","_string","\"",": ","\"","RE","LEASE","\"","}"]}} +{"type":"assistant/chunk","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} +{"type":"assistant/chunk","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} +{"type":"assistant/chunk","seq":131,"time":1785498781528,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":132,"time":1785730434538,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":133,"time":1785730434538,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c8c98565-75fb-42ef-8a86-abdcec95c42c"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} +{"type":"tool/call","seq":134,"time":1785730434538,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} +{"type":"tool/result","seq":135,"time":1785730434552,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"3755453f-7f6a-48f2-8d7a-c37c9774e38a"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"step/end","seq":136,"time":1785730434552,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":137,"time":1785730434561,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":138,"time":1783352088382,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":139,"time0":1783352088408,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,27,29,0,1,0,27,0,0,0,0,1],"texts":["Done","."," The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":153,"time":1783352088522,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":154,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":155,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":156,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. The user wants me to reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":157,"time":1783352088523,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":158,"time":1785498781555,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":244,"outputTokens":17,"cacheReadTokens":2944,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":159,"time":1785730434565,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":160,"time":1785730434565,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Done. The user wants me to reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1b426931-4d0f-4595-af9d-6eb1f5241f92"},"usage":{"inputTokens":244,"outputTokens":17,"cacheReadTokens":2944,"reasoningTokens":14}},"sourceEventSeqs":[138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} +{"type":"step/end","seq":161,"time":1785730434565,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":162,"time":1785730434565,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 2829816acb..6f69260bad 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,35 +1,37 @@ {"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784821264846,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"39efe77d-b3a4-40cc-92ed-455f6fb8e1c0"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784821264846,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464677899,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"21cab78f-0c3d-4c9b-84bc-ec18261cc838"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464677899,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464677900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487650176,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784821264889,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1784821264889,"data":{"turn":1,"step":1,"index":0,"dt":[-775561843,0,116,10,0,1,0,0,0,26,26,26,1,0,0,0,0,25,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," using"," the"," write"," tool"," with"," sand","box","_per","missions","."," Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":31,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":32,"time0":1784045703304,"data":{"turn":1,"step":1,"index":1,"dt":[52,0,25,0,0,24,1,0,0,25,1,0,0,0,51,0,0,0,0,26,0,0,0,0,56,1,0,0,0,0,0,25,0,0,0,26,1,26,1,0,0,24,0,0,0,27,0,0,0,28,0],"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","args":["","{","\"","file","_path","\"",": ","\"","es","cal","ated",".md","\"",", ","\"","content","\"",": ","\"","es","cal","ated","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," escalate"," this"," write","\"","}"]}} -{"type":"assistant/chunk","seq":84,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} -{"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} -{"type":"assistant/chunk","seq":86,"time":1785464677912,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":87,"time":1785487650188,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":88,"time":1785487650189,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f33d57d5-f27b-413a-8db7-7d8aa867596e"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} -{"type":"tool/call","seq":89,"time":1785487650189,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1785487650197,"data":{"id":"c1d51f17-f6c0-488f-936a-9d7095c9e753","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1785487650198,"data":{"id":"c1d51f17-f6c0-488f-936a-9d7095c9e753","outcome":"allowed-once"}} -{"type":"tool/result","seq":92,"time":1785487650210,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>{{cwd}}/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"ce360773-b664-403e-80f4-70368dfeed63"},"meta":{"diffs":[]}},"sourceEventSeqs":[89],"surfaceOp":"append"} -{"type":"step/end","seq":93,"time":1785487650210,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":94,"time":1785487650218,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":95,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":96,"time0":1784821264916,"data":{"turn":1,"step":2,"index":0,"dt":[-775560404,0,108,25,1,0,0,0,0,26,1,0,0,26,0,0,27,0,0],"texts":["The"," file"," was"," created"," successfully","."," The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," single"," word"," D","ONE","."]}} -{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":119,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} -{"type":"assistant/chunk","seq":120,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":121,"time":1785464677947,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":122,"time":1785487650224,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":123,"time":1785487650224,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eb0b0bd4-f4dc-4bf2-9214-632281c7cdf6"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} -{"type":"step/end","seq":124,"time":1785487650224,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":125,"time":1785487650224,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498830615,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"c2a0f1a3-11ce-4d84-bff4-49213573cb37"}]}} +{"type":"turn/start","seq":1,"time":1785821446845,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821446846,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784821264855,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498830644,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"c2a0f1a3-11ce-4d84-bff4-49213573cb37"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730482654,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"54411374-45a0-468c-b524-e5f4d0314e40"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730482654,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498830646,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730482655,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784821264889,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1784821264889,"data":{"turn":1,"step":1,"index":0,"dt":[-775561843,0,116,10,0,1,0,0,0,26,26,26,1,0,0,0,0,25,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," using"," the"," write"," tool"," with"," sand","box","_per","missions","."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","seq":33,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":34,"time0":1784045703304,"data":{"turn":1,"step":1,"index":1,"dt":[52,0,25,0,0,24,1,0,0,25,1,0,0,0,51,0,0,0,0,26,0,0,0,0,56,1,0,0,0,0,0,25,0,0,0,26,1,26,1,0,0,24,0,0,0,27,0,0,0,28,0],"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","args":["","{","\"","file","_path","\"",": ","\"","es","cal","ated",".md","\"",", ","\"","content","\"",": ","\"","es","cal","ated","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," escalate"," this"," write","\"","}"]}} +{"type":"assistant/chunk","seq":86,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"type":"assistant/chunk","seq":87,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} +{"type":"assistant/chunk","seq":88,"time":1785498830658,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":89,"time":1785730482665,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":90,"time":1785730482665,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c61cf767-078d-4fbe-8285-b17d5f651fc4"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":91,"time":1785730482665,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} +{"type":"approval/asked","seq":92,"time":1785730482674,"data":{"id":"6632f8a2-c406-429b-bbe0-5b487ebc71fb","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":93,"time":1785730482674,"data":{"id":"6632f8a2-c406-429b-bbe0-5b487ebc71fb","outcome":"allowed-once"}} +{"type":"tool/result","seq":94,"time":1785730482686,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>{{cwd}}/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"d5f7a675-6515-4976-b54a-45a4f5f0fc57"},"meta":{"diffs":[]}},"sourceEventSeqs":[91],"surfaceOp":"append"} +{"type":"step/end","seq":95,"time":1785730482686,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":96,"time":1785730482697,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":97,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":98,"time0":1784821264916,"data":{"turn":1,"step":2,"index":0,"dt":[-775560404,0,108,25,1,0,0,0,0,26,1,0,0,26,0,0,27,0,0],"texts":["The"," file"," was"," created"," successfully","."," The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," single"," word"," D","ONE","."]}} +{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":119,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":120,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":121,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":122,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":123,"time":1785498830696,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":124,"time":1785730482702,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":125,"time":1785730482702,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8d322465-9e9a-4872-a0d5-f920a666153c"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124],"surfaceOp":"append"} +{"type":"step/end","seq":126,"time":1785730482702,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":127,"time":1785730482702,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index d85c0ea447..a712edb3ea 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,31 +1,33 @@ {"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785815911816,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f9744e3d-5b10-4519-bc82-b4f890cf7659"}]}} +{"type":"turn/start","seq":1,"time":1785821384580,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821384580,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":7,"time0":1785591987500,"data":{"turn":1,"step":1,"index":0,"dt":[29,58,1,0,0,0,51,0,0,46,0,191,1,0,0,0,0,0,0,0,1,0,0,0,0,0,99],"texts":["The"," user"," wants"," me"," to"," call"," glob"," exactly"," once"," with"," pattern"," *"," and"," path"," tree",","," then"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\"."]}} -{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":36,"time0":1785591988035,"data":{"turn":1,"step":1,"index":1,"dt":[55,0,0,1,45,0,0,57,14,0,0,0,0,77,0,0,54],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]}} -{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} -{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} -{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} -{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} -{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"} -{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":64,"time0":1785591989939,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,49,36,103,1,0,0,326,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,14],"texts":["The"," glob"," result"," shows"," it"," was"," sampled"," -"," ","4"," of"," ","8"," paths"," across"," ","4"," of"," ","6"," top","-level"," entries","."," I"," need"," to"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\""," as"," instructed","."]}} -{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":106,"time0":1785591990470,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,48,0],"texts":["G","LOB","_S","AM","PL","ED"]}} -{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} -{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} -{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} -{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} -{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":1785815911838,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f9744e3d-5b10-4519-bc82-b4f890cf7659"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785815911838,"data":{"title":"Call glob exactly once with","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785815911840,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785815911840,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785591987529,"data":{"turn":1,"step":1,"index":0,"dt":[58,1,0,0,0,51,0,0,46,0,191,1,0,0,0,0,0,0,0,1,0,0,0,0,0,99,57],"texts":["The"," user"," wants"," me"," to"," call"," glob"," exactly"," once"," with"," pattern"," *"," and"," path"," tree",","," then"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\"."]}} +{"type":"assistant/chunk","seq":37,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":38,"time0":1785591988090,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,1,45,0,0,57,14,0,0,0,0,77,0,0,54,89],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]}} +{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} +{"type":"assistant/chunk","seq":58,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":59,"time":1785815911849,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":1785815911849,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"d3267d4f-77c0-4165-ba4d-22d48d666719"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":1785815911849,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} +{"type":"tool/result","seq":62,"time":1785815911873,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"ca35703b-08bd-4aaf-9a34-e2b51d1b833c"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1785815911874,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":64,"time":1785815911886,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":66,"time0":1785591989939,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,49,36,103,1,0,0,326,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,14,0],"texts":["The"," glob"," result"," shows"," it"," was"," sampled"," -"," ","4"," of"," ","8"," paths"," across"," ","4"," of"," ","6"," top","-level"," entries","."," I"," need"," to"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\""," as"," instructed","."]}} +{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":108,"time0":1785591990470,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,48,0,8],"texts":["G","LOB","_S","AM","PL","ED"]}} +{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":117,"time":1785815911893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":118,"time":1785815911894,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f11fc733-498d-44a3-9fc5-07fead8c0a68"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1785815911894,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":120,"time":1785815911894,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 07d1408c9e..270cd287f4 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,59 +1,61 @@ {"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783611702550,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783611702550,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"48987a85-c6f6-47e2-b8a2-b230d49713b3"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783611702550,"data":{"title":"Do NOT use the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464646704,"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"},"role":"user","id":"57ce5e14-8649-423a-a0ab-18ebe08b194a"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464646704,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464646705,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487602261,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783611703352,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783611703371,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,31,0,0,0,0,26,1,0,0,0,29,0,0,0,1,0,28,1,0,1,35,2,0,0,18,0,1,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," edit"," tool"," to"," replace"," \"","blue","\""," with"," \"","green","\""," in"," settings",".txt"," without"," reading"," the"," file"," first",","," and"," then"," reply"," with"," just"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":44,"time":1783611703662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":45,"time0":1783611703662,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0,30,0,0,28,34,0,1,0,0,0,25,1,1,55,0,0,1,0,0,28,0,0,29,73,0],"id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"",", ","\"","old","_string","\"",": ","\"","blue","\"",", ","\"","new","_string","\"",": ","\"","green","\"","}"]}} -{"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} -{"type":"assistant/chunk","seq":77,"time":1785464646716,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}}}} -{"type":"assistant/chunk","seq":78,"time":1785487602271,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":79,"time":1785487602271,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bd34189-fb62-4106-9c25-b6022d48e059"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} -{"type":"tool/call","seq":80,"time":1785487602272,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} -{"type":"step/end","seq":82,"time":1785487602280,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":83,"time":1785487602287,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":84,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":85,"time0":1783611704960,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,27,1,0,0,0,28,0,0,29,1,30,30,0,0,0,28,0,0,0,31,27,1,27,1,28,1,1,0,27,0,0,29,0,0,0,1,28,0,0,86,0],"texts":["The"," edit"," tool"," requires"," reading"," the"," file"," first","."," The"," user"," said"," \"","Do"," not"," read"," the"," file"," first","\""," but"," the"," tool","'s"," own"," constraints"," require"," reading","."," I"," need"," to"," follow"," the"," tool"," requirements","."," Let"," me"," read"," the"," file"," first","."]}} -{"type":"assistant/chunk","seq":129,"time":1783611705423,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":130,"time0":1783611705423,"data":{"turn":1,"step":2,"index":1,"dt":[29,1,0,0,28,0,0,0,32,59,0],"id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":142,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."}}}} -{"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}}}} -{"type":"assistant/chunk","seq":144,"time":1785464646741,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}}}} -{"type":"assistant/chunk","seq":145,"time":1785487602294,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":146,"time":1785487602294,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"554fbb57-f564-45cc-bdda-3a0ba3f846b5"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} -{"type":"tool/call","seq":147,"time":1785487602294,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":148,"time":1785487602303,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"<path>{{cwd}}/settings.txt</path>\n<type>file</type>\n<content>\n1: color: blue\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"3b8019f1-fa41-4060-80bc-5f014ba87aa9"},"meta":{"path":"{{cwd}}/settings.txt","offset":1,"lines":[{"number":1,"text":"color: blue"}],"totalLines":1}},"sourceEventSeqs":[147],"surfaceOp":"append"} -{"type":"step/end","seq":149,"time":1785487602303,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":150,"time":1785487602310,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":151,"time":1783611706300,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":152,"time0":1783611706342,"data":{"turn":1,"step":3,"index":0,"dt":[0,1,0,0,0,16,36,1,0,0,0,0,25,1,0,0,0,1,27,1,0,30,1,1,25,29,29,29,0,0,30,28,0,29,1,0,0,86,1],"texts":["The"," file"," contains"," \"","color",":"," blue","\"."," I"," need"," to"," replace"," \"","blue","\""," with"," \"","green","\"."," The"," edit"," tool"," said"," it"," requires"," reading"," first"," —"," now"," I","'ve"," read"," it",","," so"," the"," edit"," should"," work","."]}} -{"type":"assistant/chunk","seq":192,"time":1783611706798,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":193,"time0":1783611706799,"data":{"turn":1,"step":3,"index":1,"dt":[1,0,1,0,26,0,0,29,29,0,0,0,0,0,30,0,0,60,1,0,0,0,0,29,0,0,30,61,0],"id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"",", ","\"","old","_string","\"",": ","\"","blue","\"",", ","\"","new","_string","\"",": ","\"","green","\"","}"]}} -{"type":"assistant/chunk","seq":223,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."}}}} -{"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} -{"type":"assistant/chunk","seq":225,"time":1785464646768,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}}}} -{"type":"assistant/chunk","seq":226,"time":1785487602317,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":227,"time":1785487602317,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e69e381d-45f4-4a78-a2f4-7dc8940024f3"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226],"surfaceOp":"append"} -{"type":"tool/call","seq":228,"time":1785487602317,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":229,"time":1785487602332,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"3b3890ce-48c1-42c5-b0b6-b1011dc32ac6"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[228],"surfaceOp":"append"} -{"type":"step/end","seq":230,"time":1785487602332,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":231,"time":1785487602339,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":232,"time":1783611707832,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":233,"time0":1783611707858,"data":{"turn":1,"step":4,"index":0,"dt":[1,0,1,26,1,0,28,1,1,0,0,0,33,1,0,0],"texts":["The"," replacement"," was"," successful","."," I","'ll"," reply"," with"," just"," \"","D","ONE","\""," as"," instructed","."]}} -{"type":"assistant/chunk","seq":250,"time":1783611707951,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":251,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":252,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":253,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."}}}} -{"type":"assistant/chunk","seq":254,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":255,"time":1785464646797,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":20,"cacheReadTokens":3456,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":256,"time":1785487602346,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":257,"time":1785487602346,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"50f59ee9-cb45-4986-b2cd-46b7706b13b4"},"usage":{"inputTokens":202,"outputTokens":20,"cacheReadTokens":3456,"reasoningTokens":17}},"sourceEventSeqs":[232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256],"surfaceOp":"append"} -{"type":"step/end","seq":258,"time":1785487602346,"data":{"turn":1,"step":4}} -{"type":"turn/end","seq":259,"time":1785487602346,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498784836,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"065530a1-5d85-4adb-9458-6511300b63bc"}]}} +{"type":"turn/start","seq":1,"time":1785821393931,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821393932,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783611702550,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498784863,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"065530a1-5d85-4adb-9458-6511300b63bc"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730437873,"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":"35df0186-19a8-46d5-bdee-344a776db520"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730437873,"data":{"title":"Do NOT use the read","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498784864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730437874,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783611703352,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783611703371,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,31,0,0,0,0,26,1,0,0,0,29,0,0,0,1,0,28,1,0,1,35,2,0,0,18,0,1,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," edit"," tool"," to"," replace"," \"","blue","\""," with"," \"","green","\""," in"," settings",".txt"," without"," reading"," the"," file"," first",","," and"," then"," reply"," with"," just"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":46,"time":1783611703662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":47,"time0":1783611703662,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0,30,0,0,28,34,0,1,0,0,0,25,1,1,55,0,0,1,0,0,28,0,0,29,73,0],"id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"",", ","\"","old","_string","\"",": ","\"","blue","\"",", ","\"","new","_string","\"",": ","\"","green","\"","}"]}} +{"type":"assistant/chunk","seq":77,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":78,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} +{"type":"assistant/chunk","seq":79,"time":1785498784875,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}}}} +{"type":"assistant/chunk","seq":80,"time":1785730437884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":81,"time":1785730437884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fc73e1c1-7ff3-4722-9f4a-b245d8fdc040"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":82,"time":1785730437885,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} +{"type":"tool/result","seq":83,"time":1785730437894,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"5d9bc635-9fc4-4810-a49d-a627b23122e4"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[82],"surfaceOp":"append"} +{"type":"step/end","seq":84,"time":1785730437894,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":85,"time":1785730437903,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":86,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":87,"time0":1783611704960,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,27,1,0,0,0,28,0,0,29,1,30,30,0,0,0,28,0,0,0,31,27,1,27,1,28,1,1,0,27,0,0,29,0,0,0,1,28,0,0,86,0],"texts":["The"," edit"," tool"," requires"," reading"," the"," file"," first","."," The"," user"," said"," \"","Do"," not"," read"," the"," file"," first","\""," but"," the"," tool","'s"," own"," constraints"," require"," reading","."," I"," need"," to"," follow"," the"," tool"," requirements","."," Let"," me"," read"," the"," file"," first","."]}} +{"type":"assistant/chunk","seq":131,"time":1783611705423,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":132,"time0":1783611705423,"data":{"turn":1,"step":2,"index":1,"dt":[29,1,0,0,28,0,0,0,32,59,0],"id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":144,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."}}}} +{"type":"assistant/chunk","seq":145,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}}}} +{"type":"assistant/chunk","seq":146,"time":1785498784899,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":147,"time":1785730437909,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":148,"time":1785730437909,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4afe229e-bd22-4cb7-afb7-733d6ddc43bb"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147],"surfaceOp":"append"} +{"type":"tool/call","seq":149,"time":1785730437909,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} +{"type":"tool/result","seq":150,"time":1785730437919,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"<path>{{cwd}}/settings.txt</path>\n<type>file</type>\n<content>\n1: color: blue\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"07923e3b-5b5b-4698-a3fb-4c5e9bb85220"},"meta":{"path":"{{cwd}}/settings.txt","offset":1,"lines":[{"number":1,"text":"color: blue"}],"totalLines":1}},"sourceEventSeqs":[149],"surfaceOp":"append"} +{"type":"step/end","seq":151,"time":1785730437919,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":152,"time":1785730437927,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":153,"time":1783611706300,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":154,"time0":1783611706342,"data":{"turn":1,"step":3,"index":0,"dt":[0,1,0,0,0,16,36,1,0,0,0,0,25,1,0,0,0,1,27,1,0,30,1,1,25,29,29,29,0,0,30,28,0,29,1,0,0,86,1],"texts":["The"," file"," contains"," \"","color",":"," blue","\"."," I"," need"," to"," replace"," \"","blue","\""," with"," \"","green","\"."," The"," edit"," tool"," said"," it"," requires"," reading"," first"," —"," now"," I","'ve"," read"," it",","," so"," the"," edit"," should"," work","."]}} +{"type":"assistant/chunk","seq":194,"time":1783611706798,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":195,"time0":1783611706799,"data":{"turn":1,"step":3,"index":1,"dt":[1,0,1,0,26,0,0,29,29,0,0,0,0,0,30,0,0,60,1,0,0,0,0,29,0,0,30,61,0],"id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","settings",".txt","\"",", ","\"","old","_string","\"",": ","\"","blue","\"",", ","\"","new","_string","\"",": ","\"","green","\"","}"]}} +{"type":"assistant/chunk","seq":225,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."}}}} +{"type":"assistant/chunk","seq":226,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} +{"type":"assistant/chunk","seq":227,"time":1785498784922,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}}}} +{"type":"assistant/chunk","seq":228,"time":1785730437933,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":229,"time":1785730437933,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"86d62634-94f7-49fb-909f-08c3e783028f"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228],"surfaceOp":"append"} +{"type":"tool/call","seq":230,"time":1785730437933,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} +{"type":"tool/result","seq":231,"time":1785730437947,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"c3fc0325-008b-4633-8669-fcbd03b647d2"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[230],"surfaceOp":"append"} +{"type":"step/end","seq":232,"time":1785730437947,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":233,"time":1785730437955,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":234,"time":1783611707832,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":235,"time0":1783611707858,"data":{"turn":1,"step":4,"index":0,"dt":[1,0,1,26,1,0,28,1,1,0,0,0,33,1,0,0],"texts":["The"," replacement"," was"," successful","."," I","'ll"," reply"," with"," just"," \"","D","ONE","\""," as"," instructed","."]}} +{"type":"assistant/chunk","seq":252,"time":1783611707951,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":253,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":254,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":255,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."}}}} +{"type":"assistant/chunk","seq":256,"time":1783611707952,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":257,"time":1785498784950,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":20,"cacheReadTokens":3456,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":258,"time":1785730437959,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":259,"time":1785730437959,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5ccbca9e-74e5-45d1-b3c8-5c4c2edc19c3"},"usage":{"inputTokens":202,"outputTokens":20,"cacheReadTokens":3456,"reasoningTokens":17}},"sourceEventSeqs":[234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258],"surfaceOp":"append"} +{"type":"step/end","seq":260,"time":1785730437960,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":261,"time":1785730437960,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index 5a45fb8ef2..f9c19a18d3 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352099838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352099839,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"3d686e3e-2f72-4ed2-9e9f-6a3a1608d6a5"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352099839,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464645925,"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"},"role":"user","id":"7d38ab41-2809-4d1d-8972-39af495090a2"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464645925,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464645926,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487600976,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352100587,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352100616,"data":{"turn":1,"step":1,"index":0,"dt":[1,1,0,0,29,0,0,0,0,35,1,0,0,0,0,19,1,0,0,0,1,26,1,0,28,1,0,0,0,0,28,0,1,29,0,0,0,0,28,1,0,0,0,28,1,0,27,1,0,31,1,0,34,52,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," read"," tool"," with"," offset"," ","5"," and"," limit"," ","4"," to"," read"," lines"," ","5"," through"," ","8"," of"," big",".txt"," in"," the"," current"," directory","."," Then"," reply"," with"," exactly"," the"," single"," word"," D","ONE",".\n\n","Let"," me"," first"," check"," the"," current"," directory",","," then"," read"," the"," file","."]}} -{"type":"assistant/chunk","seq":64,"time":1783352101062,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":65,"time0":1783352101062,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,18,0,0,0,29,1,27,0,0,0,34,0,56,1,0,0,0,28,29,61,0],"id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","args":["","{","\"","file","_path","\"",": ","\"","big",".txt","\"",", ","\"","offset","\"",": ","5",", ","\"","limit","\"",": ","4","}"]}} -{"type":"assistant/chunk","seq":89,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."}}}} -{"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} -{"type":"assistant/chunk","seq":91,"time":1785464645938,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}}}} -{"type":"assistant/chunk","seq":92,"time":1785487600987,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":93,"time":1785487600987,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"481d76e5-c02d-4a81-b45c-3086a96a9e6f"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} -{"type":"tool/call","seq":94,"time":1785487600987,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":95,"time":1785487600999,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false}],"role":"user","id":"18089abb-5a47-474f-8b5f-609985acb116"},"meta":{"path":"{{cwd}}/big.txt","offset":5,"lines":[{"number":5,"text":"line five"},{"number":6,"text":"line six"},{"number":7,"text":"line seven"},{"number":8,"text":"line eight"}],"totalLines":10}},"sourceEventSeqs":[94],"surfaceOp":"append"} -{"type":"step/end","seq":96,"time":1785487600999,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":97,"time":1785487601008,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":98,"time":1783352102123,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":99,"time0":1783352102145,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,29,1,0,0,0,29,0,32,0,24,1,37,1,0,0,0,0,27,1,0,0,0,29,0],"texts":["The"," read"," tool"," returned"," lines"," ","5"," through"," ","8"," as"," expected","."," Now"," I"," need"," to"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":126,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":127,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":128,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":129,"time":1783352102358,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":130,"time":1783352102358,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":131,"time":1785464645963,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":132,"time":1785487601014,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":133,"time":1785487601014,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"22bc75cc-9b63-45fd-b82c-c5b6d7168b81"},"usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}},"sourceEventSeqs":[98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} -{"type":"step/end","seq":134,"time":1785487601014,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":135,"time":1785487601015,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498783700,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"a6db8c80-6239-490e-8ee4-1e2074d73a19"}]}} +{"type":"turn/start","seq":1,"time":1785821392334,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821392334,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352099840,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498783725,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"a6db8c80-6239-490e-8ee4-1e2074d73a19"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730436765,"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":"d5453309-c7da-4071-b46f-5441ca4a828b"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730436765,"data":{"title":"Use the read tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498783727,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730436766,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352100587,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352100616,"data":{"turn":1,"step":1,"index":0,"dt":[1,1,0,0,29,0,0,0,0,35,1,0,0,0,0,19,1,0,0,0,1,26,1,0,28,1,0,0,0,0,28,0,1,29,0,0,0,0,28,1,0,0,0,28,1,0,27,1,0,31,1,0,34,52,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," read"," tool"," with"," offset"," ","5"," and"," limit"," ","4"," to"," read"," lines"," ","5"," through"," ","8"," of"," big",".txt"," in"," the"," current"," directory","."," Then"," reply"," with"," exactly"," the"," single"," word"," D","ONE",".\n\n","Let"," me"," first"," check"," the"," current"," directory",","," then"," read"," the"," file","."]}} +{"type":"assistant/chunk","seq":66,"time":1783352101062,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":67,"time0":1783352101062,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,18,0,0,0,29,1,27,0,0,0,34,0,56,1,0,0,0,28,29,61,0],"id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","args":["","{","\"","file","_path","\"",": ","\"","big",".txt","\"",", ","\"","offset","\"",": ","5",", ","\"","limit","\"",": ","4","}"]}} +{"type":"assistant/chunk","seq":91,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."}}}} +{"type":"assistant/chunk","seq":92,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} +{"type":"assistant/chunk","seq":93,"time":1785498783738,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}}}} +{"type":"assistant/chunk","seq":94,"time":1785730436777,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":95,"time":1785730436777,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d403fe3d-677c-4ef2-8083-4d4ddf59c12c"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":96,"time":1785730436778,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} +{"type":"tool/result","seq":97,"time":1785730436787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false}],"role":"user","id":"1f3d5f99-c881-4e6c-a379-042a557300be"},"meta":{"path":"{{cwd}}/big.txt","offset":5,"lines":[{"number":5,"text":"line five"},{"number":6,"text":"line six"},{"number":7,"text":"line seven"},{"number":8,"text":"line eight"}],"totalLines":10}},"sourceEventSeqs":[96],"surfaceOp":"append"} +{"type":"step/end","seq":98,"time":1785730436787,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":99,"time":1785730436797,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":100,"time":1783352102123,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":101,"time0":1783352102145,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,29,1,0,0,0,29,0,32,0,24,1,37,1,0,0,0,0,27,1,0,0,0,29,0],"texts":["The"," read"," tool"," returned"," lines"," ","5"," through"," ","8"," as"," expected","."," Now"," I"," need"," to"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":128,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":129,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":130,"time":1783352102357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":131,"time":1783352102358,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":132,"time":1783352102358,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":133,"time":1785498783763,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":134,"time":1785730436802,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":135,"time":1785730436803,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4f7f5f2a-8fbd-4221-b813-b2a5272e4d4e"},"usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}},"sourceEventSeqs":[100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134],"surfaceOp":"append"} +{"type":"step/end","seq":136,"time":1785730436803,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":137,"time":1785730436803,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index be9b355b51..9bab6eb99e 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352072468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352072469,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"235bfbd8-ace3-469a-a51f-e32fa0693748"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352072469,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464642559,"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"},"role":"user","id":"f89fadc6-9592-489c-a773-c6bd3db25a0c"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464642559,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464642559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487595802,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352073210,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352073245,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,33,1,0,0,0,0,35,1,0,0,0,36,0,0,1,34,0,0,0,35,1,0,104,0],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," greeting",".txt"," using"," the"," read"," tool"," (","not"," bash","),"," then"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":37,"time":1783352073527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":38,"time0":1783352073527,"data":{"turn":1,"step":1,"index":1,"dt":[35,0,0,0,35,0,34,0,0,35,39,0],"id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","args":["","{","\"","file","_path","\"",": ","\"","gre","eting",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":51,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785464642570,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}}}} -{"type":"assistant/chunk","seq":54,"time":1785487595811,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785487595811,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"765ac9b1-2385-4017-9e5a-f57f369afab4"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785487595812,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":57,"time":1785487595822,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"3a7e1551-3344-4f9f-a599-4b1cf2659b55"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":58,"time":1785487595822,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":59,"time":1785487595831,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":60,"time":1783352074786,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":61,"time0":1783352074815,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,27,0,26,0,0,0,0,29,0,0,1,0,0,28,1,0,0,0,32,28,0,0,29,0,1,0,0,0,26,1,0],"texts":["The"," user"," asked"," me"," to"," read"," the"," file"," and"," then"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."," I","'ve"," read"," the"," file","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":98,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":99,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":100,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":101,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to read the file and then reply with exactly the single word \"DONE\". I've read the file. Now I just need to reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":102,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":103,"time":1785464642596,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":200,"outputTokens":40,"cacheReadTokens":2816,"reasoningTokens":37}}}} -{"type":"assistant/chunk","seq":104,"time":1785487595837,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":105,"time":1785487595837,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to read the file and then reply with exactly the single word \"DONE\". I've read the file. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aeeecf49-0a00-4293-a43a-90b4b1f84249"},"usage":{"inputTokens":200,"outputTokens":40,"cacheReadTokens":2816,"reasoningTokens":37}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104],"surfaceOp":"append"} -{"type":"step/end","seq":106,"time":1785487595837,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":107,"time":1785487595837,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498779270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"3b9f093c-8fed-49d1-8252-7e6560033ebd"}]}} +{"type":"turn/start","seq":1,"time":1785821386137,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821386137,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352072470,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498779296,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"3b9f093c-8fed-49d1-8252-7e6560033ebd"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730432294,"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":"d2b5abf5-ff22-4268-bac3-b6338c6e2f02"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730432294,"data":{"title":"Use the read tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498779297,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730432295,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352073210,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352073245,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,33,1,0,0,0,0,35,1,0,0,0,36,0,0,1,34,0,0,0,35,1,0,104,0],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," greeting",".txt"," using"," the"," read"," tool"," (","not"," bash","),"," then"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":39,"time":1783352073527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":40,"time0":1783352073527,"data":{"turn":1,"step":1,"index":1,"dt":[35,0,0,0,35,0,34,0,0,35,39,0],"id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","args":["","{","\"","file","_path","\"",": ","\"","gre","eting",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":54,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785498779307,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":56,"time":1785730432304,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785730432304,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"14818f08-4172-4f2b-9487-9add755c17e4"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785730432305,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} +{"type":"tool/result","seq":59,"time":1785730432314,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"bfa7d99e-7643-412d-a13c-4d647afa8dc6"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1785730432314,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1785730432324,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1783352074786,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":63,"time0":1783352074815,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,27,0,26,0,0,0,0,29,0,0,1,0,0,28,1,0,0,0,32,28,0,0,29,0,1,0,0,0,26,1,0],"texts":["The"," user"," asked"," me"," to"," read"," the"," file"," and"," then"," reply"," with"," exactly"," the"," single"," word"," \"","D","ONE","\"."," I","'ve"," read"," the"," file","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":100,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":101,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":102,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":103,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to read the file and then reply with exactly the single word \"DONE\". I've read the file. Now I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":104,"time":1783352075045,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":105,"time":1785498779335,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":200,"outputTokens":40,"cacheReadTokens":2816,"reasoningTokens":37}}}} +{"type":"assistant/chunk","seq":106,"time":1785730432330,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":107,"time":1785730432330,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to read the file and then reply with exactly the single word \"DONE\". I've read the file. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"90e72cf4-dc61-4349-8c5e-6b835ea94f4d"},"usage":{"inputTokens":200,"outputTokens":40,"cacheReadTokens":2816,"reasoningTokens":37}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106],"surfaceOp":"append"} +{"type":"step/end","seq":108,"time":1785730432330,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":109,"time":1785730432330,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index c3d728d54d..52310b746b 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,46 +1,48 @@ {"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352092220,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352092221,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"1b6fe460-f6d1-44b5-933f-01febc2c2664"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352092221,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464645122,"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"},"role":"user","id":"2914e6f7-611b-4493-b005-836111b83ab6"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464645122,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464645123,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487599659,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352093090,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352093118,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,1,0,0,35,0,0,0,0,19,1,0,0,0,0,29,0,0,27,1,28,0,0,0,0,32,0,0,0,0,0,30,1,0,32,24,1,0,111,1],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," data",".txt"," using"," the"," read"," tool","\n","2","."," Replace"," its"," entire"," contents"," with"," exactly"," \"","re","placed","\""," using"," the"," write"," tool","\n","3","."," Reply"," with"," exactly"," \"","D","ONE","\""]}} -{"type":"assistant/chunk","seq":50,"time":1783352093494,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":51,"time0":1783352093494,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,29,0,0,0,29,0,62,0],"id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":63,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""}}}} -{"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} -{"type":"assistant/chunk","seq":65,"time":1785464645134,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}}}} -{"type":"assistant/chunk","seq":66,"time":1785487599669,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":67,"time":1785487599669,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"237debac-b061-44f3-9f63-e436dcdf9fac"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66],"surfaceOp":"append"} -{"type":"tool/call","seq":68,"time":1785487599669,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":69,"time":1785487599679,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"4548e80c-f3df-411c-8573-f20e6b8cb253"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[68],"surfaceOp":"append"} -{"type":"step/end","seq":70,"time":1785487599679,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":71,"time":1785487599687,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":72,"time":1783352094575,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":73,"time0":1783352094604,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,26,0,29,1,0,0,35,0,0,85,0],"texts":["The"," file"," contains"," \"","original"," contents","\"."," Now"," I","'ll"," replace"," it"," with"," \"","re","placed","\"."]}} -{"type":"assistant/chunk","seq":90,"time":1783352094781,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":91,"time0":1783352094781,"data":{"turn":1,"step":2,"index":1,"dt":[26,1,0,0,29,1,0,0,25,0,35,1,0,1,22,1,0,29,36,0],"id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","re","placed","\"","}"]}} -{"type":"assistant/chunk","seq":112,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."}}}} -{"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} -{"type":"assistant/chunk","seq":114,"time":1785464645161,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":115,"time":1785487599693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":116,"time":1785487599693,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0318b4f4-6c75-4f7d-b314-a913d953c240"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} -{"type":"tool/call","seq":117,"time":1785487599693,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":118,"time":1785487599708,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false}],"role":"user","id":"e99d7d39-c38e-4c1a-a074-5793dd42ccb5"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[117],"surfaceOp":"append"} -{"type":"step/end","seq":119,"time":1785487599708,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":120,"time":1785487599716,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":121,"time":1783352096187,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":122,"time0":1783352096215,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,31,0,1,28,0,0,0,0,1,31,0,0,0],"texts":["The"," file"," has"," been"," replaced"," successfully","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":138,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":139,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":140,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":141,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been replaced successfully. Now I just reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":142,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":143,"time":1785464645189,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":225,"outputTokens":19,"cacheReadTokens":2944,"reasoningTokens":16}}}} -{"type":"assistant/chunk","seq":144,"time":1785487599722,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":145,"time":1785487599722,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file has been replaced successfully. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e51620e0-0cb8-404f-8b90-aa93396fd2ee"},"usage":{"inputTokens":225,"outputTokens":19,"cacheReadTokens":2944,"reasoningTokens":16}},"sourceEventSeqs":[121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144],"surfaceOp":"append"} -{"type":"step/end","seq":146,"time":1785487599722,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":147,"time":1785487599722,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498782591,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"e1697ae3-3d38-4492-9dad-5115f056934a"}]}} +{"type":"turn/start","seq":1,"time":1785821390865,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821390865,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352092223,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498782618,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"e1697ae3-3d38-4492-9dad-5115f056934a"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730435638,"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":"3d35609b-3d69-4790-8078-c79eff29bbd8"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730435638,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498782619,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730435639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352093090,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352093118,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,1,0,0,35,0,0,0,0,19,1,0,0,0,0,29,0,0,27,1,28,0,0,0,0,32,0,0,0,0,0,30,1,0,32,24,1,0,111,1],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," data",".txt"," using"," the"," read"," tool","\n","2","."," Replace"," its"," entire"," contents"," with"," exactly"," \"","re","placed","\""," using"," the"," write"," tool","\n","3","."," Reply"," with"," exactly"," \"","D","ONE","\""]}} +{"type":"assistant/chunk","seq":52,"time":1783352093494,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":53,"time0":1783352093494,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,29,0,0,0,29,0,62,0],"id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":65,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""}}}} +{"type":"assistant/chunk","seq":66,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":67,"time":1785498782629,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}}}} +{"type":"assistant/chunk","seq":68,"time":1785730435649,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1785730435650,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"895e81ca-cb3b-4046-9672-bb69ed494e69"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1785730435650,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":71,"time":1785730435660,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"e28d284b-5ba3-45bf-b77e-20961a1453ce"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1785730435660,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1785730435669,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1783352094575,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":75,"time0":1783352094604,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,26,0,29,1,0,0,35,0,0,85,0],"texts":["The"," file"," contains"," \"","original"," contents","\"."," Now"," I","'ll"," replace"," it"," with"," \"","re","placed","\"."]}} +{"type":"assistant/chunk","seq":92,"time":1783352094781,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":93,"time0":1783352094781,"data":{"turn":1,"step":2,"index":1,"dt":[26,1,0,0,29,1,0,0,25,0,35,1,0,1,22,1,0,29,36,0],"id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","re","placed","\"","}"]}} +{"type":"assistant/chunk","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."}}}} +{"type":"assistant/chunk","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} +{"type":"assistant/chunk","seq":116,"time":1785498782653,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":117,"time":1785730435674,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":118,"time":1785730435674,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eace2627-b5ff-437e-8950-9d079036d385"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117],"surfaceOp":"append"} +{"type":"tool/call","seq":119,"time":1785730435674,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} +{"type":"tool/result","seq":120,"time":1785730435689,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false}],"role":"user","id":"d8cab06b-66b9-4415-bd6c-2ef964263fcc"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[119],"surfaceOp":"append"} +{"type":"step/end","seq":121,"time":1785730435689,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":122,"time":1785730435697,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":123,"time":1783352096187,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":124,"time0":1783352096215,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,31,0,1,28,0,0,0,0,1,31,0,0,0],"texts":["The"," file"," has"," been"," replaced"," successfully","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":140,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":141,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":142,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":143,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been replaced successfully. Now I just reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":144,"time":1783352096309,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":145,"time":1785498782682,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":225,"outputTokens":19,"cacheReadTokens":2944,"reasoningTokens":16}}}} +{"type":"assistant/chunk","seq":146,"time":1785730435701,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":147,"time":1785730435701,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file has been replaced successfully. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1411eb9b-9cc6-48fa-8d1e-2f4b91b8b9aa"},"usage":{"inputTokens":225,"outputTokens":19,"cacheReadTokens":2944,"reasoningTokens":16}},"sourceEventSeqs":[123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146],"surfaceOp":"append"} +{"type":"step/end","seq":148,"time":1785730435701,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":149,"time":1785730435702,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 363dbbf76d..461d8999fe 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352078754,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352078754,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"00bf73c4-e27c-47e7-94c3-3a609cf2f646"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352078754,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464643342,"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"},"role":"user","id":"7339c27b-cc02-4659-a94d-8ed744d14220"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464643342,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464643343,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487597128,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352079333,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352079392,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,1,0,30,28,0,0,28,29,1,0,0,0,1,27,0,0,1,0,0,27,1,0,0,0,84,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," named"," notes",".txt"," with"," the"," content"," \"","hello"," world","\""," using"," the"," write"," tool",","," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":38,"time":1783352079680,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":39,"time0":1783352079681,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,32,0,0,27,29,0,0,0,0,29,0,0,0,27,60,1],"id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"",", ","\"","content","\"",": ","\"","hello"," world","\"","}"]}} -{"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} -{"type":"assistant/chunk","seq":62,"time":1785464643354,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":63,"time":1785487597138,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785487597138,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"85a1663c-aa2d-42c1-8cbc-1f98f8c27428"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} -{"type":"tool/call","seq":65,"time":1785487597138,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":66,"time":1785487597155,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>{{cwd}}/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"6ec81e96-566f-4440-953f-3b47fe2e78c4"},"meta":{"diffs":[]}},"sourceEventSeqs":[65],"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785487597155,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":68,"time":1785487597163,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":69,"time":1783352080942,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":70,"time0":1783352080971,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,1,27,1,0,0,0,1,27,0,1,0,0,0],"texts":["The"," file"," has"," been"," created","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":87,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":88,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":89,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":90,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":91,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":92,"time":1785464643384,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":93,"time":1785487597168,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1785487597168,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"90b50e3d-9b4f-458f-b393-3b5376b40a1d"},"usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1785487597168,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":96,"time":1785487597168,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498780355,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"8316fddb-e888-4ba9-b280-2d2bb8717633"}]}} +{"type":"turn/start","seq":1,"time":1785821387697,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821387697,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352078756,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498780381,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"8316fddb-e888-4ba9-b280-2d2bb8717633"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730433386,"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":"b54d8375-2277-4551-bd0b-06b40d1ad59a"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730433386,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498780382,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730433387,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352079333,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352079392,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,1,0,30,28,0,0,28,29,1,0,0,0,1,27,0,0,1,0,0,27,1,0,0,0,84,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," named"," notes",".txt"," with"," the"," content"," \"","hello"," world","\""," using"," the"," write"," tool",","," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":40,"time":1783352079680,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":41,"time0":1783352079681,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,32,0,0,27,29,0,0,0,0,29,0,0,0,27,60,1],"id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"",", ","\"","content","\"",": ","\"","hello"," world","\"","}"]}} +{"type":"assistant/chunk","seq":62,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":63,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} +{"type":"assistant/chunk","seq":64,"time":1785498780392,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":65,"time":1785730433397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":66,"time":1785730433397,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8dbcba45-0348-43c0-9d46-42663b547cad"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":67,"time":1785730433397,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} +{"type":"tool/result","seq":68,"time":1785730433412,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>{{cwd}}/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"818c9501-638f-4de8-8810-6d32c3b3e93a"},"meta":{"diffs":[]}},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1785730433412,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":70,"time":1785730433423,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":71,"time":1783352080942,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":72,"time0":1783352080971,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,1,27,1,0,0,0,1,27,0,1,0,0,0],"texts":["The"," file"," has"," been"," created","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":89,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":90,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":91,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":92,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":93,"time":1783352081056,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":94,"time":1785498780420,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":95,"time":1785730433427,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":96,"time":1785730433428,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"91664038-fb2c-4305-b1a5-02daaf93aeca"},"usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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":1785730433428,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":98,"time":1785730433428,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl index 13ca169587..bc5f42573a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"469cf62c-893d-4524-88af-38cd59d8a0ef"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464662495,"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"},"role":"user","id":"d6a33901-468a-434d-8fdd-acf43961f3e0"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464662495,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464662495,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487626391,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} -{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} -{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464662506,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":34,"time":1785487626400,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487626400,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"45059642-a91c-426d-b898-68ec81a0114e"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487626400,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785487626400,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498807231,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"a56c3c26-071d-407c-8900-d84de1222c0c"}]}} +{"type":"turn/start","seq":1,"time":1785821424016,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821424016,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498807263,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"a56c3c26-071d-407c-8900-d84de1222c0c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730463095,"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":"fe569552-1e83-41d2-a240-55df5da79bc9"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730463095,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498807265,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730463096,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498807274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730463106,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730463106,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"09e21cd4-86fd-4088-9419-54f7e95ee4da"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[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":1785730463106,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730463106,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index 798f4fbca9..79bfe25837 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,49 +1,51 @@ {"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"baf33b2e-dabe-4cde-97a5-2b29fb7665bb"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783962504117,"data":{"title":"Call the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464666547,"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"},"role":"user","id":"ac7423df-00db-47c5-9fef-afcb2c36e44b"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464666547,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464666548,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487632988,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783962505340,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783962505372,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,93,1,0,0,0,0,0,0,0,0,0,0,71,0,0,0,1,0,6,1,0,0,112,0,0,0,0,0,0,2,1],"texts":["The"," user"," wants"," me"," to"," run"," the"," bash"," tool"," with"," the"," command"," \"","echo"," HE","LL","O","\"."," If"," it","'s"," rejected",","," ret","ry"," once","."," Then"," quote"," the"," final"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":43,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":44,"time0":1783962505688,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,0,0,30,2,0,0,25,0,30,1,0,0,29,32,0,0,1,0,22,1,100,1,0],"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} -{"type":"assistant/chunk","seq":71,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}}} -{"type":"assistant/chunk","seq":72,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":73,"time":1785464666560,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}}} -{"type":"assistant/chunk","seq":74,"time":1785487632999,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":75,"time":1785487632999,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."},{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2d5a2b70-023b-4e14-b5a0-469900aea39c"},"usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],"surfaceOp":"append"} -{"type":"tool/call","seq":76,"time":1785487632999,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":77,"time":1785487633017,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":78,"time":1785487633024,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":6.010500000000093}} -{"type":"tool/result","seq":79,"time":1785487633024,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_VAByyMjsct4c7P6k1ysX9256"},"content":[{"type":"tool-result","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true}],"role":"user","id":"df16da32-fe5e-4064-ae30-7f686bb029b3"}},"sourceEventSeqs":[76],"surfaceOp":"append"} -{"type":"step/end","seq":80,"time":1785487633024,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":81,"time":1785487633032,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":82,"time":1783962507131,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":83,"time0":1783962507232,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,0,0,0,20,0,0,0,0,0,54,0,1,0,66,0],"texts":["The"," first"," call"," was"," rejected"," by"," policy","."," The"," user"," said"," to"," ret","ry"," once","."," Let"," me"," ret","ry","."]}} -{"type":"assistant/chunk","seq":104,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":105,"time0":1783962507397,"data":{"turn":1,"step":2,"index":1,"dt":[0,1,0,28,0,0,0,29,0,108,0,1,0,0,0,0,0,0,1,0,0,0,8,0,58,0],"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} -{"type":"assistant/chunk","seq":132,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}}} -{"type":"assistant/chunk","seq":133,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":134,"time":1785464666604,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":135,"time":1785487633039,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":136,"time":1785487633039,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."},{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ab582acd-26bf-4fb2-9920-9f4d81593821"},"usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}},"sourceEventSeqs":[82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135],"surfaceOp":"append"} -{"type":"tool/call","seq":137,"time":1785487633039,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":138,"time":1785487633050,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":139,"time":1785487633056,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"pass","exitCode":0,"durationMs":5.970291000000088}} -{"type":"tool/result","seq":140,"time":1785487633057,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856"},"content":[{"type":"tool-result","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"51560389-2b7d-451e-b861-397d2486cf4d"}},"sourceEventSeqs":[137],"surfaceOp":"append"} -{"type":"step/end","seq":141,"time":1785487633057,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":142,"time":1785487633064,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":143,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":144,"time0":1783962508803,"data":{"turn":1,"step":3,"index":0,"dt":[0,1,7,1,0,0,27,0,0,0,0,34,0],"texts":["The"," second"," attempt"," succeeded","."," The"," final"," result"," is"," \"","HE","LL","O","\"."]}} -{"type":"assistant/chunk","seq":158,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":159,"time0":1783962508901,"data":{"turn":1,"step":3,"index":1,"dt":[0,1,28,1,0,0,0,0,52,1,0,0],"texts":["The"," final"," tool"," result"," verb","atim",":\n\n","```\n","HE","LL","O","\n","```"]}} -{"type":"assistant/chunk","seq":172,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}}} -{"type":"assistant/chunk","seq":173,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} -{"type":"assistant/chunk","seq":174,"time":1785464666635,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":175,"time":1785487633071,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":176,"time":1785487633072,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."},{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bda15839-39cc-47d2-a61e-97bae3744a63"},"usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}},"sourceEventSeqs":[143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175],"surfaceOp":"append"} -{"type":"step/end","seq":177,"time":1785487633072,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":178,"time":1785487633072,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498813580,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"ff685d2f-c629-45a2-a6b1-9aba6679e804"}]}} +{"type":"turn/start","seq":1,"time":1785821430450,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821430451,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783962504152,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498813609,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"ff685d2f-c629-45a2-a6b1-9aba6679e804"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730468551,"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":"30410f7f-af50-4d13-898a-6fc04927fd93"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730468551,"data":{"title":"Call the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498813611,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730468552,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783962505340,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783962505372,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,93,1,0,0,0,0,0,0,0,0,0,0,71,0,0,0,1,0,6,1,0,0,112,0,0,0,0,0,0,2,1],"texts":["The"," user"," wants"," me"," to"," run"," the"," bash"," tool"," with"," the"," command"," \"","echo"," HE","LL","O","\"."," If"," it","'s"," rejected",","," ret","ry"," once","."," Then"," quote"," the"," final"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":45,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":46,"time0":1783962505688,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,0,0,30,2,0,0,25,0,30,1,0,0,29,32,0,0,1,0,22,1,100,1,0],"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} +{"type":"assistant/chunk","seq":73,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}}} +{"type":"assistant/chunk","seq":74,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":75,"time":1785498813622,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":76,"time":1785730468562,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":77,"time":1785730468562,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."},{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"94313bbb-d025-469b-bb55-59f6d1adb8cc"},"usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":78,"time":1785730468563,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":79,"time":1785730468581,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":80,"time":1785730468590,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":7.9223749999998745}} +{"type":"tool/result","seq":81,"time":1785730468590,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_VAByyMjsct4c7P6k1ysX9256"},"content":[{"type":"tool-result","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true}],"role":"user","id":"f5632aca-fad4-49f3-b764-c9dd83ba3d46"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"step/end","seq":82,"time":1785730468590,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":83,"time":1785730468601,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":84,"time":1783962507131,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":85,"time0":1783962507232,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,0,0,0,20,0,0,0,0,0,54,0,1,0,66,0],"texts":["The"," first"," call"," was"," rejected"," by"," policy","."," The"," user"," said"," to"," ret","ry"," once","."," Let"," me"," ret","ry","."]}} +{"type":"assistant/chunk","seq":106,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":107,"time0":1783962507397,"data":{"turn":1,"step":2,"index":1,"dt":[0,1,0,28,0,0,0,29,0,108,0,1,0,0,0,0,0,0,1,0,0,0,8,0,58,0],"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} +{"type":"assistant/chunk","seq":134,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}}} +{"type":"assistant/chunk","seq":135,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":136,"time":1785498813672,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":137,"time":1785730468606,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":138,"time":1785730468607,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."},{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6d7223c6-2023-4d08-a82d-a2269670c108"},"usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}},"sourceEventSeqs":[84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137],"surfaceOp":"append"} +{"type":"tool/call","seq":139,"time":1785730468607,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":140,"time":1785730468618,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} +{"type":"hook/result","seq":141,"time":1785730468623,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"pass","exitCode":0,"durationMs":5.523832999999968}} +{"type":"tool/result","seq":142,"time":1785730468624,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856"},"content":[{"type":"tool-result","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"69f60e3f-b776-4c68-8cd0-e70511d01d07"}},"sourceEventSeqs":[139],"surfaceOp":"append"} +{"type":"step/end","seq":143,"time":1785730468624,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":144,"time":1785730468634,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":145,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":146,"time0":1783962508803,"data":{"turn":1,"step":3,"index":0,"dt":[0,1,7,1,0,0,27,0,0,0,0,34,0],"texts":["The"," second"," attempt"," succeeded","."," The"," final"," result"," is"," \"","HE","LL","O","\"."]}} +{"type":"assistant/chunk","seq":160,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":161,"time0":1783962508901,"data":{"turn":1,"step":3,"index":1,"dt":[0,1,28,1,0,0,0,0,52,1,0,0],"texts":["The"," final"," tool"," result"," verb","atim",":\n\n","```\n","HE","LL","O","\n","```"]}} +{"type":"assistant/chunk","seq":174,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}}} +{"type":"assistant/chunk","seq":175,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":176,"time":1785498813704,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":177,"time":1785730468639,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":178,"time":1785730468639,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."},{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"144a17d3-106c-4f62-867d-a9d4d97aceab"},"usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}},"sourceEventSeqs":[145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177],"surfaceOp":"append"} +{"type":"step/end","seq":179,"time":1785730468639,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":180,"time":1785730468639,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index a36b665436..3de51d6886 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -1,35 +1,39 @@ {"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352196662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352196662,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"259fd217-9330-452b-9446-b2c983030ab5"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352196662,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464667419,"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"},"role":"user","id":"5a836dcf-e893-41ad-bce2-d93725d8efe3"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464667419,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464667420,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487634351,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352197457,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352197485,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,29,28,1,0,0,0,0,28,0,1,0,0,0,31,0,29,1,57,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":31,"time":1783352197719,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":32,"time0":1783352197720,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,0,0,28,1,0,0,28,1,28,1,0,0,28,1,0,0,0,28,1,59,0],"id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":57,"time":1783352197953,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352197954,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1785464667431,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":60,"time":1785487634362,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1785487634362,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b1dc04ba-2d4d-4a03-a222-c84023d61fcf"},"usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1785487634362,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} -{"type":"hook/invoked","seq":63,"time":1785487634382,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":64,"time":1785487634385,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":2.3400830000000497}} -{"type":"tool/result","seq":65,"time":1785487634385,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_HbCMzTslWBZTSphWN0z97382"},"content":[{"type":"tool-result","toolCallId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"4966d71e-40f8-48cd-b4cd-a8194c6d2a51"}},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"user/message","seq":66,"time":1785487634385,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"6014ce29-7652-44ca-ac90-776b4174825c"},"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785487634386,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":68,"time":1785487634390,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":69,"time":1783352199062,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":70,"time0":1783352199089,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,1,30,1,0,0,0,25,0,0,28,31,1,1,0,0,23,1,0,0,28,1,0,0,0,28,0],"texts":["The"," user"," asked"," me"," to"," report"," the"," tool"," result"," verb","atim","."," The"," result"," was"," \"","HE","LL","O","\""," with"," an"," exit"," code"," of"," ","0"," (","success",")."]}} -{"type":"assistant/chunk","seq":100,"time":1783352199288,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":101,"time0":1783352199288,"data":{"turn":1,"step":2,"index":1,"dt":[0,28,1,0,0,0,27,0,1,0,28,0,0,35,1,0,1,0,0],"texts":["The"," tool"," result"," was",":\n\n","```\n","HE","LL","O","\n","```\n\n","It"," completed"," successfully"," with"," exit"," code"," ","0","."]}} -{"type":"assistant/chunk","seq":121,"time":1783352199411,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result was \"HELLO\" with an exit code of 0 (success)."}}}} -{"type":"assistant/chunk","seq":122,"time":1783352199411,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}}}} -{"type":"assistant/chunk","seq":123,"time":1785464667466,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":124,"time":1785487634396,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":125,"time":1785487634396,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result was \"HELLO\" with an exit code of 0 (success)."},{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"06b0c92d-35e3-4bfa-8ea6-95aebc93d426"},"usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124],"surfaceOp":"append"} -{"type":"step/end","seq":126,"time":1785487634397,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":127,"time":1785487634397,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498814980,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"f13c12f8-c187-4bae-bab7-a63d04e66f38"}]}} +{"type":"turn/start","seq":1,"time":1785821431674,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821431674,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352196664,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498815008,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"f13c12f8-c187-4bae-bab7-a63d04e66f38"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730469687,"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":"6bba4af4-6406-410c-b730-541278dcdbd7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730469687,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498815010,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730469688,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352197457,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352197485,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,29,28,1,0,0,0,0,28,0,1,0,0,0,31,0,29,1,57,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":33,"time":1783352197719,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":34,"time0":1783352197720,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,0,0,28,1,0,0,28,1,28,1,0,0,28,1,0,0,0,28,1,59,0],"id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":59,"time":1783352197953,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":60,"time":1783352197954,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1785498815021,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":62,"time":1785730469697,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1785730469697,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3f69acce-e9e4-484d-9f67-a3be89ac6b0d"},"usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1785730469698,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":65,"time":1785730469715,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":66,"time":1785730469718,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":2.467875000000049}} +{"type":"tool/result","seq":67,"time":1785730469718,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_HbCMzTslWBZTSphWN0z97382"},"content":[{"type":"tool-result","toolCallId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"cb4649d1-9c25-40de-820c-7c7719f8a938"}},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":68,"time":1785730469718,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"61e6c6d6-872c-4dd7-9771-53ced14a120d"}]}} +{"type":"step/end","seq":69,"time":1785730469719,"data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","seq":70,"time":1785730469719,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":71,"time":1785730469725,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":72,"time":1785730469725,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"61e6c6d6-872c-4dd7-9771-53ced14a120d"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":73,"time":1783352199089,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":74,"time0":1783352199089,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,30,1,0,0,0,25,0,0,28,31,1,1,0,0,23,1,0,0,28,1,0,0,0,28,0,0,0],"texts":["The"," user"," asked"," me"," to"," report"," the"," tool"," result"," verb","atim","."," The"," result"," was"," \"","HE","LL","O","\""," with"," an"," exit"," code"," of"," ","0"," (","success",")."]}} +{"type":"assistant/chunk","seq":104,"time":1783352199288,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":105,"time0":1783352199316,"data":{"turn":1,"step":2,"index":1,"dt":[1,0,0,0,27,0,1,0,28,0,0,35,1,0,1,0,0,1,0],"texts":["The"," tool"," result"," was",":\n\n","```\n","HE","LL","O","\n","```\n\n","It"," completed"," successfully"," with"," exit"," code"," ","0","."]}} +{"type":"assistant/chunk","seq":125,"time":1785498815058,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result was \"HELLO\" with an exit code of 0 (success)."}}}} +{"type":"assistant/chunk","seq":126,"time":1785498815058,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}}}} +{"type":"assistant/chunk","seq":127,"time":1785498815058,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":128,"time":1785730469730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":129,"time":1785730469730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result was \"HELLO\" with an exit code of 0 (success)."},{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"33bc2b6b-1d60-4143-971a-8ea2dab595bd"},"usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}},"sourceEventSeqs":[73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} +{"type":"step/end","seq":130,"time":1785730469730,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":131,"time":1785730469730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 7f795e20ea..e917d074d7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -1,36 +1,38 @@ {"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8fb2206b-c264-4e64-be5d-bc0da215f974"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464665727,"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"},"role":"user","id":"9278cc85-fded-40a6-a6e8-1e4bb83f90fb"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464665727,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464665727,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487631724,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352172117,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,27,0,1,0,29,0,0,0,28,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":25,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":26,"time0":1783352172290,"data":{"turn":1,"step":1,"index":1,"dt":[28,1,0,0,29,0,0,0,0,57,1,0,0,0,28,0,0,30,0,0,0,32,59,0],"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":51,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":52,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785464665739,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":54,"time":1785487631733,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785487631734,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c44cff4-49fe-4c6a-ba33-3957b9408bf8"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785487631734,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} -{"type":"hook/invoked","seq":57,"time":1785487631734,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":58,"time":1785487631739,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":4.100459000000001}} -{"type":"approval/asked","seq":59,"time":1785487631739,"data":{"id":"cd9c385e-eb14-4f71-9db5-e1619852554f","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":60,"time":1785487631740,"data":{"id":"cd9c385e-eb14-4f71-9db5-e1619852554f","outcome":"rejected"}} -{"type":"tool/result","seq":61,"time":1785487631740,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311"},"content":[{"type":"tool-result","toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true}],"role":"user","id":"e9546691-4a06-44bc-9ee3-fc9689b1b8a2"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":62,"time":1785487631740,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":63,"time":1785487631745,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":64,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":65,"time0":1783352173644,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,24,0,1,0,28,1,0,0,29,0,1,0,26,1,0,0,0,33],"texts":["The"," bash"," tool"," returned"," an"," error"," saying"," it"," requires"," manual"," approval"," in"," this"," session","."," I","'ll"," report"," this"," verb","atim","."]}} -{"type":"assistant/chunk","seq":87,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":88,"time0":1783352173854,"data":{"turn":1,"step":2,"index":1,"dt":[0,25,27,0,1,0,0,25,1,0,0,0,0,30,0,0,0,1,0,0,0],"texts":["The"," tool"," result"," I"," got"," back"," verb","atim"," is",":\n\n","```\n","Error",":"," bash"," requires"," manual"," approval"," in"," this"," session","\n","```"]}} -{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} -{"type":"assistant/chunk","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} -{"type":"assistant/chunk","seq":112,"time":1785464665759,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":113,"time":1785487631751,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":114,"time":1785487631751,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddd3f480-0e14-4967-9998-afe44c7dcad7"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} -{"type":"step/end","seq":115,"time":1785487631751,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":116,"time":1785487631752,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498812164,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8b8672e5-2bff-458d-b482-51b703f61dcb"}]}} +{"type":"turn/start","seq":1,"time":1785821429300,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821429300,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352171527,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498812202,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8b8672e5-2bff-458d-b482-51b703f61dcb"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730467496,"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":"9d525efc-a44b-4217-a882-d29d8feb042f"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730467496,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498812203,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730467497,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352172117,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,27,0,1,0,29,0,0,0,28,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":27,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":28,"time0":1783352172290,"data":{"turn":1,"step":1,"index":1,"dt":[28,1,0,0,29,0,0,0,0,57,1,0,0,0,28,0,0,30,0,0,0,32,59,0],"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785498812216,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":56,"time":1785730467507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785730467508,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ebb7de11-f58a-4114-8598-99b5dce6fc6b"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785730467508,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1785730467508,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1785730467513,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":3.9570000000001073}} +{"type":"approval/asked","seq":61,"time":1785730467513,"data":{"id":"664315fe-3ca7-41fb-89a6-770d64be625a","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":62,"time":1785730467513,"data":{"id":"664315fe-3ca7-41fb-89a6-770d64be625a","outcome":"rejected"}} +{"type":"tool/result","seq":63,"time":1785730467513,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311"},"content":[{"type":"tool-result","toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true}],"role":"user","id":"b224966f-c7d7-4c83-9b50-e7c2988d7d79"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1785730467513,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1785730467520,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":67,"time0":1783352173644,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,24,0,1,0,28,1,0,0,29,0,1,0,26,1,0,0,0,33],"texts":["The"," bash"," tool"," returned"," an"," error"," saying"," it"," requires"," manual"," approval"," in"," this"," session","."," I","'ll"," report"," this"," verb","atim","."]}} +{"type":"assistant/chunk","seq":89,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":90,"time0":1783352173854,"data":{"turn":1,"step":2,"index":1,"dt":[0,25,27,0,1,0,0,25,1,0,0,0,0,30,0,0,0,1,0,0,0],"texts":["The"," tool"," result"," I"," got"," back"," verb","atim"," is",":\n\n","```\n","Error",":"," bash"," requires"," manual"," approval"," in"," this"," session","\n","```"]}} +{"type":"assistant/chunk","seq":112,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} +{"type":"assistant/chunk","seq":113,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} +{"type":"assistant/chunk","seq":114,"time":1785498812238,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":115,"time":1785730467525,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":116,"time":1785730467526,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"707dacf7-7d41-4906-92f7-25656fdb1b4f"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"step/end","seq":117,"time":1785730467526,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":118,"time":1785730467526,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index f24cbd8d7b..b3aac9fb00 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"686d91c6-7880-4506-b887-11e1233815ee"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464664893,"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"},"role":"user","id":"32a58f8b-d4f0-4aed-ac97-cd21d1d986d0"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464664894,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464664894,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487630468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352166048,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352166075,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,1,0,0,28,0,27,0,58,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":25,"time":1783352166250,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":26,"time0":1783352166250,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31,59,0],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":51,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":52,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785464664905,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":54,"time":1785487630478,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785487630478,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"903bc7b0-7685-4c62-88f8-805322a4b76f"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785487630478,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} -{"type":"hook/invoked","seq":57,"time":1785487630478,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":58,"time":1785487630483,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":4.245374999999967}} -{"type":"tool/result","seq":59,"time":1785487630484,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_JliP571Bh0QQ8QExbSPk0080"},"content":[{"type":"tool-result","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true}],"role":"user","id":"e5212e56-9d5d-41fb-96d6-0b4c38d85be1"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":60,"time":1785487630484,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":61,"time":1785487630490,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":62,"time":1783352167440,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":63,"time0":1783352167469,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}} -{"type":"assistant/chunk","seq":84,"time":1783352167643,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":85,"time0":1783352167672,"data":{"turn":1,"step":2,"index":1,"dt":[29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1,0,0],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}} -{"type":"assistant/chunk","seq":115,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} -{"type":"assistant/chunk","seq":116,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} -{"type":"assistant/chunk","seq":117,"time":1785464664923,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":118,"time":1785487630496,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":119,"time":1785487630497,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0448ea8e-f773-4138-bb08-450aca04a0b4"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118],"surfaceOp":"append"} -{"type":"step/end","seq":120,"time":1785487630497,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":121,"time":1785487630497,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498810733,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8b449df9-9149-4e05-8464-5fccbbbf06ba"}]}} +{"type":"turn/start","seq":1,"time":1785821428130,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821428131,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352165198,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498810766,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8b449df9-9149-4e05-8464-5fccbbbf06ba"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730466373,"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":"37e3a9e3-c9f8-431f-8af2-aa16d270e534"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730466373,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498810768,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730466374,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352166048,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352166075,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,1,0,0,28,0,27,0,58,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":27,"time":1783352166250,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":28,"time0":1783352166250,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31,59,0],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785498810778,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":56,"time":1785730466384,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785730466384,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04bc8b4d-2ae5-4bfd-9cb1-19209c7d2f5f"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785730466385,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1785730466385,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1785730466389,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":3.6819170000001122}} +{"type":"tool/result","seq":61,"time":1785730466390,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_JliP571Bh0QQ8QExbSPk0080"},"content":[{"type":"tool-result","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true}],"role":"user","id":"e8988570-1579-41e9-bf2c-be3fa97db46f"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1785730466390,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1785730466396,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783352167440,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":65,"time0":1783352167469,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}} +{"type":"assistant/chunk","seq":86,"time":1783352167643,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":87,"time0":1783352167672,"data":{"turn":1,"step":2,"index":1,"dt":[29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1,0,0],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}} +{"type":"assistant/chunk","seq":117,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} +{"type":"assistant/chunk","seq":118,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} +{"type":"assistant/chunk","seq":119,"time":1785498810797,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":120,"time":1785730466401,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":121,"time":1785730466401,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cb2cc300-1026-4bb9-8cc2-3c8869d13528"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120],"surfaceOp":"append"} +{"type":"step/end","seq":122,"time":1785730466401,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":123,"time":1785730466401,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index cb25d1c6bb..d25d2a6db0 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 365f5b3ff1..b03b6b7b4e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,21 +1,25 @@ {"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"b9040d2c-a08a-4eab-a7dd-64238c58bda8"},"surfaceOp":"append"} -{"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"0287f2e5-d8e1-4d44-a0da-7b2dbf7d3d12"},"surfaceOp":"append"} -{"type":"session/title","seq":3,"time":1785122243327,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":4,"time":1785464664105,"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"},"role":"user","id":"a42993ea-2b48-4028-9949-795d94dd0070"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785464664105,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785464664105,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785487629168,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352160566,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352161228,"data":{"turn":1,"step":1,"index":0,"dt":[1,106,28,0,29,0,0,1,0,27,1,0,0,28,0,0,28,1],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":30,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":31,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} -{"type":"assistant/chunk","seq":32,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464664117,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":34,"time":1785487629197,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487629197,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"40c344fb-4386-4d81-81c3-3b2fed5fe656"},"usage":{"inputTokens":2892,"outputTokens":22,"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],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487629198,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785487629198,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498809587,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"c403acd5-efa4-4c8c-948f-211f3b23c93f"}]}} +{"type":"turn/start","seq":1,"time":1785821426913,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821426914,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"hook/invoked","seq":3,"time":1785821426915,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} +{"type":"hook/result","seq":4,"time":1785821426920,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":4.92145800000003}} +{"type":"step/start","seq":5,"time":1785821426949,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730465275,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"c403acd5-efa4-4c8c-948f-211f3b23c93f"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1785821426950,"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":"2514657a-056c-46a8-ac90-c0169b42f048"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1785821426950,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"8006cbd3-a233-4d35-a61b-1a9e0c6b4545"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1785821426950,"data":{"title":"What is my favorite color?","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785821426951,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785821426951,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1783352161228,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":13,"time0":1783352161229,"data":{"turn":1,"step":1,"index":0,"dt":[106,28,0,29,0,0,1,0,27,1,0,0,28,0,0,28,1,0],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} +{"type":"assistant/chunk","seq":32,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":33,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":34,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":35,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498809628,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730465284,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785821426965,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785821426965,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"097b2896-4bc1-4d33-be4b-5b7f4fc6dd41"},"usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785821426965,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785821426966,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 027b2541ea..f2e54f6499 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,37 +1,41 @@ {"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"dc6d2561-0aae-4bce-8c6d-097f93330763"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784522140647,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464668253,"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"},"role":"user","id":"125ed3b8-6ead-476d-bd28-90d7be58b4f8"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464668253,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464668253,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487635637,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1784522142866,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,10,0,0,1,0,0,27,0,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," just"," the"," word"," \"","FIR","ST","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":25,"time":1784522142905,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} -{"type":"assistant/chunk","seq":27,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} -{"type":"assistant/chunk","seq":28,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."}}}} -{"type":"assistant/chunk","seq":29,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} -{"type":"assistant/chunk","seq":30,"time":1785464668263,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":31,"time":1785487635646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785487635646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"66dbe584-222b-4f9d-968a-7795527c6fa8"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785487635646,"data":{"turn":1,"step":1}} -{"type":"hook/invoked","seq":34,"time":1785487635647,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} -{"type":"hook/result","seq":35,"time":1785487635656,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":8.217624999999998}} -{"type":"steering/message","seq":36,"time":1785487635656,"data":{"turn":1,"message":{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"87000731-3998-4b7e-8189-144955b00152"}},"surfaceOp":"append"} -{"type":"step/start","seq":37,"time":1785487635662,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":38,"time":1784522144018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":39,"time0":1784522144049,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,28,0,0,0,0,0,58,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]}} -{"type":"assistant/chunk","seq":57,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":58,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} -{"type":"assistant/chunk","seq":59,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} -{"type":"assistant/chunk","seq":60,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} -{"type":"assistant/chunk","seq":61,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} -{"type":"assistant/chunk","seq":62,"time":1785464668286,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":63,"time":1785487635667,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":1785487635668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2e79ef31-7641-4480-9202-164c51de1142"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487635668,"data":{"turn":1,"step":2}} -{"type":"hook/invoked","seq":66,"time":1785487635668,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} -{"type":"hook/result","seq":67,"time":1785487635670,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":2.5693330000001424}} -{"type":"turn/end","seq":68,"time":1785487635670,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498816452,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"4f322d30-9425-4c61-afbb-ee5432ba6552"}]}} +{"type":"turn/start","seq":1,"time":1785821432845,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821432845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784522140648,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498816483,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"4f322d30-9425-4c61-afbb-ee5432ba6552"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730470752,"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":"b3914542-4c81-4699-b07e-863d2ef3a818"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730470752,"data":{"title":"Reply with the single word","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498816486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730470753,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1784522142866,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,10,0,0,1,0,0,27,0,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," just"," the"," word"," \"","FIR","ST","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":27,"time":1784522142905,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":28,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":29,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":30,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":31,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":32,"time":1785498816496,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":33,"time":1785730470762,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785730470762,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8e6cc17c-3742-45bb-aa1b-bdd280793231"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730470763,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":36,"time":1785730470763,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} +{"type":"hook/result","seq":37,"time":1785730470771,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":7.99508400000002}} +{"type":"agent/inbox/spliced","seq":38,"time":1785498816507,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"ef13b378-3c05-4cc0-b7e9-872782fb45f7"}]}} +{"type":"agent/inbox/spliced","seq":39,"time":1785730470771,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":40,"time":1785730470780,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":41,"time":1785730470780,"data":{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"ef13b378-3c05-4cc0-b7e9-872782fb45f7"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":42,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":43,"time0":1784522144049,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,28,0,0,0,0,0,58,0,0,0,0,0,6,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]}} +{"type":"assistant/chunk","seq":61,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":62,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":63,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":64,"time":1785498816521,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"type":"assistant/chunk","seq":65,"time":1785498816521,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":66,"time":1785498816521,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":67,"time":1785730470785,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":68,"time":1785730470785,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"153b4095-a1e9-43d2-8421-ad6f6a91f723"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1785730470785,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":70,"time":1785730470785,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} +{"type":"hook/result","seq":71,"time":1785730470788,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":2.633624999999938}} +{"type":"turn/end","seq":72,"time":1785730470788,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl index cd8f6ad415..374ae19eb3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"08999a8e-cd74-4eb9-add1-7bc52aa20084"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464663275,"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"},"role":"user","id":"b376445d-3a21-4476-b55e-f88ec3d9fe4d"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464663275,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464663275,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487627609,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} -{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} -{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464663285,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":34,"time":1785487627619,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487627620,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6ac3a473-31cb-4995-bc95-581a472e4640"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487627620,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785487627620,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498808383,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"14d17f1b-63f3-478a-8859-2c0d8cbbf38d"}]}} +{"type":"turn/start","seq":1,"time":1785821425396,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821425396,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498808410,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"14d17f1b-63f3-478a-8859-2c0d8cbbf38d"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730464165,"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":"a4958955-419b-49bf-848b-d404c24e0061"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730464165,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498808411,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730464166,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498808421,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730464177,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730464177,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c6f7b850-9c28-41a0-ae85-27c03578ecba"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[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":1785730464177,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730464177,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 80578f51a9..cd72379fa4 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"1c53543b-9440-4cec-a37c-0c449a6cb383"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783986962235,"data":{"title":"Call the bash tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464670943,"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"},"role":"user","id":"ff336a20-82e0-4a73-bc6b-1357602a8b00"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464670943,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464670943,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487639374,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783986963134,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,25,53,0,0,0,0,0,0,8,0,0,0,31,0,62,1],"texts":["The"," user"," wants"," me"," to"," call"," the"," bash"," tool"," once"," with"," `","echo"," HE","LL","O","`,"," then"," quote"," the"," result"," verb","atim"," and"," stop","."]}} -{"type":"assistant/chunk","seq":34,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":35,"time0":1783986963345,"data":{"turn":1,"step":1,"index":1,"dt":[0,24,0,0,0,28,0,0,0,31,1,28,0,0,0,32,25,0,0,0,0,30,0,114,1,1],"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} -{"type":"assistant/chunk","seq":62,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} -{"type":"assistant/chunk","seq":63,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":64,"time":1785464670957,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}}} -{"type":"assistant/chunk","seq":65,"time":1785487639384,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":66,"time":1785487639384,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."},{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"075bbcde-55d2-4a6e-a3d5-570778bb0d3a"},"usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} -{"type":"tool/call","seq":67,"time":1785487639384,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":68,"time":1785487639402,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":69,"time":1785487639405,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":2.249166999999943}} -{"type":"tool/result","seq":70,"time":1785487639405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1rmSWHhVchVg7PDTmegT0421"},"content":[{"type":"tool-result","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true}],"role":"user","id":"40ab7149-be9a-43a3-9a0b-f1161b5add09"}},"sourceEventSeqs":[67],"surfaceOp":"append"} -{"type":"step/end","seq":71,"time":1785487639406,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":72,"time":1785487639412,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":73,"time":1783986964809,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":74,"time0":1783986964835,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,28,1,0,28,6,1,24,0,31,30,28,1,31,87,0],"texts":["The"," tool"," output"," was"," rejected"," by"," code","x"," policy","."," Let"," me"," quote"," what"," I"," got"," back","."]}} -{"type":"assistant/chunk","seq":92,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":93,"time0":1783986965133,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,100,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0],"texts":["```\n","<","tool","_result",">","tool"," output"," rejected"," by"," code","x"," policy",":"," summarize"," instead","</","tool","_result",">\n","```"]}} -{"type":"assistant/chunk","seq":113,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} -{"type":"assistant/chunk","seq":114,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"```\n<tool_result>tool output rejected by codex policy: summarize instead</tool_result>\n```"}}}} -{"type":"assistant/chunk","seq":115,"time":1785464670995,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":116,"time":1785487639418,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":117,"time":1785487639418,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."},{"type":"text","text":"```\n<tool_result>tool output rejected by codex policy: summarize instead</tool_result>\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4fe8322e-a06f-4328-9bfb-e63234061f94"},"usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}},"sourceEventSeqs":[73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116],"surfaceOp":"append"} -{"type":"step/end","seq":118,"time":1785487639418,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":119,"time":1785487639418,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498820675,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"3c6acf4d-845a-44e9-9fde-0ff9611f1b89"}]}} +{"type":"turn/start","seq":1,"time":1785821436673,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821436674,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783986962240,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498820704,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"3c6acf4d-845a-44e9-9fde-0ff9611f1b89"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730473886,"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":"90fd41ec-8404-4c36-8c80-9eec3dda86a7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730473886,"data":{"title":"Call the bash tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498820706,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730473887,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783986963134,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,25,53,0,0,0,0,0,0,8,0,0,0,31,0,62,1],"texts":["The"," user"," wants"," me"," to"," call"," the"," bash"," tool"," once"," with"," `","echo"," HE","LL","O","`,"," then"," quote"," the"," result"," verb","atim"," and"," stop","."]}} +{"type":"assistant/chunk","seq":36,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":37,"time0":1783986963345,"data":{"turn":1,"step":1,"index":1,"dt":[0,24,0,0,0,28,0,0,0,31,1,28,0,0,0,32,25,0,0,0,0,30,0,114,1,1],"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","E","cho"," HE","LL","O"," to"," stdout","\"","}"]}} +{"type":"assistant/chunk","seq":64,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} +{"type":"assistant/chunk","seq":65,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":66,"time":1785498820716,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":67,"time":1785730473898,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":68,"time":1785730473898,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."},{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"51288fec-fd4d-4434-97cc-4903b54338a3"},"usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":69,"time":1785730473898,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":70,"time":1785730473917,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":71,"time":1785730473920,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":2.548084000000017}} +{"type":"tool/result","seq":72,"time":1785730473920,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1rmSWHhVchVg7PDTmegT0421"},"content":[{"type":"tool-result","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true}],"role":"user","id":"da710864-a024-42ae-925f-f2b989b014ef"}},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"step/end","seq":73,"time":1785730473920,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":74,"time":1785730473927,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":75,"time":1783986964809,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":76,"time0":1783986964835,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,28,1,0,28,6,1,24,0,31,30,28,1,31,87,0],"texts":["The"," tool"," output"," was"," rejected"," by"," code","x"," policy","."," Let"," me"," quote"," what"," I"," got"," back","."]}} +{"type":"assistant/chunk","seq":94,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":95,"time0":1783986965133,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,100,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0],"texts":["```\n","<","tool","_result",">","tool"," output"," rejected"," by"," code","x"," policy",":"," summarize"," instead","</","tool","_result",">\n","```"]}} +{"type":"assistant/chunk","seq":115,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} +{"type":"assistant/chunk","seq":116,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"```\n<tool_result>tool output rejected by codex policy: summarize instead</tool_result>\n```"}}}} +{"type":"assistant/chunk","seq":117,"time":1785498820752,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":118,"time":1785730473933,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":119,"time":1785730473933,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."},{"type":"text","text":"```\n<tool_result>tool output rejected by codex policy: summarize instead</tool_result>\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ad398545-2fd8-419c-937b-44c6387c11e3"},"usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}},"sourceEventSeqs":[75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118],"surfaceOp":"append"} +{"type":"step/end","seq":120,"time":1785730473933,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":121,"time":1785730473933,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index dfa163108d..085aca05da 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -1,35 +1,39 @@ {"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352228441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352228442,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"c6f5fc7f-1cd9-4dd5-9fa8-7114f73faa31"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352228442,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464671933,"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"},"role":"user","id":"fe3c7a87-569c-4292-935b-ead75dbee967"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464671933,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464671934,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487640668,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352229106,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352229134,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,1,0,0,0,0,27,33,1,0,0,0,0,27,0,0,85,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":31,"time":1783352229338,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":32,"time0":1783352229338,"data":{"turn":1,"step":1,"index":1,"dt":[28,0,0,0,28,1,0,0,0,57,0,0,0,0,28,0,29,0,1,0,0,27,60,1],"id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":57,"time":1783352229598,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352229598,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1785464671945,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":60,"time":1785487640678,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1785487640679,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b820d9e3-587f-4490-9a98-f1314e95a143"},"usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1785487640679,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} -{"type":"hook/invoked","seq":63,"time":1785487640697,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":64,"time":1785487640700,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":2.185165999999981}} -{"type":"tool/result","seq":65,"time":1785487640700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Q6wHtakaip2QNfIXaVJY5458"},"content":[{"type":"tool-result","toolCallId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"d0a00e91-15e1-4a23-8073-2aceee095caf"}},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"user/message","seq":66,"time":1785487640700,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"02d6859d-1c69-47e7-8ee7-b3cb969dea1d"},"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785487640700,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":68,"time":1785487640705,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":69,"time":1783352230950,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":70,"time0":1783352230976,"data":{"turn":1,"step":2,"index":0,"dt":[29,1,0,0,26,1,0,0,0,1,27,1,27,0,28,29,0,32,0,24,1,0,0,28,0,0],"texts":["The"," user"," asked"," me"," to"," report"," the"," tool"," result"," verb","atim","."," The"," result"," I"," got"," back"," is",":\n\n","HE","LL","O","\n\n","That","'s"," it","."]}} -{"type":"assistant/chunk","seq":97,"time":1783352231232,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":98,"time0":1783352231262,"data":{"turn":1,"step":2,"index":1,"dt":[1,29,28,28,0,1,0,0,29,1,0,0],"texts":["The"," tool"," result"," I"," received"," is",":\n\n","```\n","HE","LL","O","\n","```"]}} -{"type":"assistant/chunk","seq":111,"time":1783352231379,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."}}}} -{"type":"assistant/chunk","seq":112,"time":1783352231379,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}}}} -{"type":"assistant/chunk","seq":113,"time":1785464671980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":114,"time":1785487640711,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":115,"time":1785487640711,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."},{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b15dab22-b5a4-49be-8a4a-1a2352b30a4c"},"usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114],"surfaceOp":"append"} -{"type":"step/end","seq":116,"time":1785487640711,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":117,"time":1785487640711,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498822108,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"428246ac-6aee-4609-9ff4-5c5f5755fb61"}]}} +{"type":"turn/start","seq":1,"time":1785821437930,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821437930,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352228443,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498822136,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"428246ac-6aee-4609-9ff4-5c5f5755fb61"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730474943,"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":"442c4504-a8f1-4e47-9314-e3d2badd93df"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730474943,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498822138,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730474944,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352229106,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352229134,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,1,0,0,0,0,27,33,1,0,0,0,0,27,0,0,85,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":33,"time":1783352229338,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":34,"time0":1783352229338,"data":{"turn":1,"step":1,"index":1,"dt":[28,0,0,0,28,1,0,0,0,57,0,0,0,0,28,0,29,0,1,0,0,27,60,1],"id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":59,"time":1783352229598,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":60,"time":1783352229598,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1785498822149,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":62,"time":1785730474954,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1785730474954,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1ad8b612-1d4f-4ca4-a8a1-88751a998560"},"usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1785730474955,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":65,"time":1785730474973,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":66,"time":1785730474976,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":2.959500000000048}} +{"type":"tool/result","seq":67,"time":1785730474976,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Q6wHtakaip2QNfIXaVJY5458"},"content":[{"type":"tool-result","toolCallId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false}],"role":"user","id":"a1ffa84c-10eb-42aa-b775-3d8cec3dfee4"}},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":68,"time":1785730474976,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"9648bfd5-b442-468d-8d74-894327b97204"}]}} +{"type":"step/end","seq":69,"time":1785730474976,"data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","seq":70,"time":1785730474976,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":71,"time":1785730474984,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":72,"time":1785730474984,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"9648bfd5-b442-468d-8d74-894327b97204"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":73,"time":1783352231005,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":74,"time0":1783352231006,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,26,1,0,0,0,1,27,1,27,0,28,29,0,32,0,24,1,0,0,28,0,0,1,30],"texts":["The"," user"," asked"," me"," to"," report"," the"," tool"," result"," verb","atim","."," The"," result"," I"," got"," back"," is",":\n\n","HE","LL","O","\n\n","That","'s"," it","."]}} +{"type":"assistant/chunk","seq":101,"time":1783352231263,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":102,"time0":1783352231292,"data":{"turn":1,"step":2,"index":1,"dt":[28,28,0,1,0,0,29,1,0,0,0,0],"texts":["The"," tool"," result"," I"," received"," is",":\n\n","```\n","HE","LL","O","\n","```"]}} +{"type":"assistant/chunk","seq":115,"time":1785498822186,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."}}}} +{"type":"assistant/chunk","seq":116,"time":1785498822187,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":117,"time":1785498822187,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":118,"time":1785730474989,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":119,"time":1785730474989,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."},{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"12bf71d7-c8bb-404f-84fb-e5964de5c19f"},"usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}},"sourceEventSeqs":[73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118],"surfaceOp":"append"} +{"type":"step/end","seq":120,"time":1785730474989,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":121,"time":1785730474989,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index c5fa190ef3..eb521c4df8 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352214604,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352214605,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"4ebe4e7b-056e-4ce0-b75c-787b2f4c087b"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352214605,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464669942,"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"},"role":"user","id":"b67c6ae6-36de-4e49-8c53-b2ad76d9bbca"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464669943,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464669944,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487638138,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352215351,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352215383,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,28,1,0,1,0,27,1,27,1,56,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":25,"time":1783352215555,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":26,"time0":1783352215557,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,1,0,30,0,0,0,25,1,28,0,0,1,27,1,0,77,1,0,12,10,1],"id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":51,"time":1783352215801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":52,"time":1783352215801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785464669958,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":54,"time":1785487638148,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785487638148,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04a441f1-611c-457b-9933-36345078024e"},"usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785487638148,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} -{"type":"hook/invoked","seq":57,"time":1785487638148,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":58,"time":1785487638154,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":4.3560419999998885}} -{"type":"tool/result","seq":59,"time":1785487638154,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_tv0SMeLXaTuyuVrOxnV97085"},"content":[{"type":"tool-result","toolCallId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true}],"role":"user","id":"9f55d77a-7412-43cf-b36f-a9579bbd245e"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":60,"time":1785487638154,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":61,"time":1785487638160,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":62,"time":1783352216878,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":63,"time0":1783352216892,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,25,1,28,0,1,0,28,1,0,0,0,28,1,0,0,0,0,29,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy"," in"," this"," session","."," I"," need"," to"," report"," this"," result"," verb","atim"," to"," the"," user","."]}} -{"type":"assistant/chunk","seq":86,"time":1783352217035,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":87,"time0":1783352217064,"data":{"turn":1,"step":2,"index":1,"dt":[1,36,0,1,25,0,1,0,0,37,0,0,0,0,0,18,0,0,0,0,30,1,0,0],"texts":["The"," tool"," result"," I"," got"," back"," verb","atim"," is",":\n\n","```\n","Error",":"," bash"," is"," disabled"," by"," code","x"," policy"," in"," this"," session","\n","```"]}} -{"type":"assistant/chunk","seq":112,"time":1783352217214,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."}}}} -{"type":"assistant/chunk","seq":113,"time":1783352217214,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}}} -{"type":"assistant/chunk","seq":114,"time":1785464669991,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":171,"outputTokens":49,"cacheReadTokens":2816,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":115,"time":1785487638166,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":116,"time":1785487638166,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"91f7e8dc-87a3-4a40-869c-76320be42139"},"usage":{"inputTokens":171,"outputTokens":49,"cacheReadTokens":2816,"reasoningTokens":23}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} -{"type":"step/end","seq":117,"time":1785487638167,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":118,"time":1785487638167,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498819334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"83299ced-cede-4e39-a425-4b58915f8c06"}]}} +{"type":"turn/start","seq":1,"time":1785821435310,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821435310,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352214607,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498819368,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"83299ced-cede-4e39-a425-4b58915f8c06"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730472832,"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":"a01d2417-d639-4920-ae79-bd3aa6b5c3bb"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730472832,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498819371,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730472833,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352215351,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352215383,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,28,1,0,1,0,27,1,27,1,56,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":27,"time":1783352215555,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":28,"time0":1783352215557,"data":{"turn":1,"step":1,"index":1,"dt":[0,29,0,1,0,30,0,0,0,25,1,28,0,0,1,27,1,0,77,1,0,12,10,1],"id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1783352215801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783352215801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785498819386,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":56,"time":1785730472843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785730472843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"90653282-1d79-4100-a6bc-7ed4b7ea20db"},"usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785730472843,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1785730472843,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1785730472848,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":3.695083000000068}} +{"type":"tool/result","seq":61,"time":1785730472848,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_tv0SMeLXaTuyuVrOxnV97085"},"content":[{"type":"tool-result","toolCallId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true}],"role":"user","id":"886077ec-20d8-47f5-a72c-b4f08ece29d4"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1785730472848,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1785730472856,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783352216878,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":65,"time0":1783352216892,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,25,1,28,0,1,0,28,1,0,0,0,28,1,0,0,0,0,29,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy"," in"," this"," session","."," I"," need"," to"," report"," this"," result"," verb","atim"," to"," the"," user","."]}} +{"type":"assistant/chunk","seq":88,"time":1783352217035,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":89,"time0":1783352217064,"data":{"turn":1,"step":2,"index":1,"dt":[1,36,0,1,25,0,1,0,0,37,0,0,0,0,0,18,0,0,0,0,30,1,0,0],"texts":["The"," tool"," result"," I"," got"," back"," verb","atim"," is",":\n\n","```\n","Error",":"," bash"," is"," disabled"," by"," code","x"," policy"," in"," this"," session","\n","```"]}} +{"type":"assistant/chunk","seq":114,"time":1783352217214,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."}}}} +{"type":"assistant/chunk","seq":115,"time":1783352217214,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}}} +{"type":"assistant/chunk","seq":116,"time":1785498819413,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":171,"outputTokens":49,"cacheReadTokens":2816,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":117,"time":1785730472863,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":118,"time":1785730472863,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f996eea7-d53a-42a6-a0bf-a7b16bcb49d2"},"usage":{"inputTokens":171,"outputTokens":49,"cacheReadTokens":2816,"reasoningTokens":23}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1785730472864,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":120,"time":1785730472864,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index cb25d1c6bb..d25d2a6db0 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index da4009ee72..e046950b21 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,21 +1,25 @@ {"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"87985a8c-5eb2-4c3a-940d-be7511c83f37"},"surfaceOp":"append"} -{"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"31d3c6cf-c935-40f2-ab70-0890224bd27b"},"surfaceOp":"append"} -{"type":"session/title","seq":3,"time":1785122250006,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":4,"time":1785464669090,"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"},"role":"user","id":"a63c512d-a47c-452d-97f0-b988bd4ad298"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785464669090,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785464669091,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785487636894,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352209710,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352210353,"data":{"turn":1,"step":1,"index":0,"dt":[0,117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0,0,0],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} -{"type":"assistant/chunk","seq":47,"time":1783352210755,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":48,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":49,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":50,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} -{"type":"assistant/chunk","seq":51,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":52,"time":1785464669102,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} -{"type":"assistant/chunk","seq":53,"time":1785487636904,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":54,"time":1785487636905,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"56abee5c-67b9-49fd-8ad7-c47dcdfdf086"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1785487636905,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":56,"time":1785487636905,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498817948,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8d3df251-9583-4ddb-9ead-a50df35bbac6"}]}} +{"type":"turn/start","seq":1,"time":1785821434012,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821434012,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"hook/invoked","seq":3,"time":1785821434013,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} +{"type":"hook/result","seq":4,"time":1785821434017,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":3.7565839999999753}} +{"type":"step/start","seq":5,"time":1785821434044,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730471801,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8d3df251-9583-4ddb-9ead-a50df35bbac6"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1785821434044,"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":"e5f01e9b-c7c7-4f33-b3aa-b949ad404d98"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1785821434044,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"7c3bd47e-8613-4853-bf55-769ece5c609e"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1785821434044,"data":{"title":"What is my favorite color?","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785821434045,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785821434045,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1783352210353,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":13,"time0":1783352210353,"data":{"turn":1,"step":1,"index":0,"dt":[117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0,0,0,1],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} +{"type":"assistant/chunk","seq":51,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":52,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":53,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":54,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} +{"type":"assistant/chunk","seq":55,"time":1785498817991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":56,"time":1785730471812,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":57,"time":1785821434055,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":58,"time":1785821434055,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"05782b9b-b4ce-4a05-abce-50c05c8a9259"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1785821434055,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":60,"time":1785821434055,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index 87a677cbe2..11f329a53b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,37 +1,41 @@ {"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"677a4c49-fd0c-41e5-a71a-aada966652d8"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784522152397,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464672848,"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"},"role":"user","id":"81f2d9de-e04e-45c6-8cca-c266f88e2ba5"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464672848,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464672849,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487641940,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784522153749,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1784522153750,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,1,0,0,0,0,0,0,9,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":25,"time":1784522153762,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1784522153785,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} -{"type":"assistant/chunk","seq":27,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} -{"type":"assistant/chunk","seq":28,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} -{"type":"assistant/chunk","seq":29,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} -{"type":"assistant/chunk","seq":30,"time":1785464672860,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":31,"time":1785487641949,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785487641949,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"107fb4d6-6fe8-4ea7-b9de-5f409c8d8252"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785487641949,"data":{"turn":1,"step":1}} -{"type":"hook/invoked","seq":34,"time":1785487641949,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} -{"type":"hook/result","seq":35,"time":1785487641957,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":7.179292000000032}} -{"type":"steering/message","seq":36,"time":1785487641957,"data":{"turn":1,"message":{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"0e3d386e-56e1-49e4-a24f-847686c129d7"}},"surfaceOp":"append"} -{"type":"step/start","seq":37,"time":1785487641963,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":38,"time":1784522154866,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":39,"time0":1784522154898,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]}} -{"type":"assistant/chunk","seq":57,"time":1784522154978,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":58,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} -{"type":"assistant/chunk","seq":59,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} -{"type":"assistant/chunk","seq":60,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} -{"type":"assistant/chunk","seq":61,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} -{"type":"assistant/chunk","seq":62,"time":1785464672884,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":63,"time":1785487641969,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":1785487641969,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"43ef997f-baa6-4e8e-8765-de3fcc8103e2"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487641969,"data":{"turn":1,"step":2}} -{"type":"hook/invoked","seq":66,"time":1785487641969,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} -{"type":"hook/result","seq":67,"time":1785487641971,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":2.4302500000001146}} -{"type":"turn/end","seq":68,"time":1785487641971,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498823341,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"26dda5a7-298f-4809-96ba-e8be4381afa5"}]}} +{"type":"turn/start","seq":1,"time":1785821439140,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821439141,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784522152399,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498823368,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"26dda5a7-298f-4809-96ba-e8be4381afa5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730476001,"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":"af67bfc1-182f-4dc5-bbb4-093463938e34"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730476001,"data":{"title":"Reply with the single word","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498823370,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730476002,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784522153749,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1784522153750,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,1,0,0,0,0,0,0,9,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":27,"time":1784522153762,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":28,"time":1784522153785,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":29,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":30,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":31,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":32,"time":1785498823379,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":33,"time":1785730476011,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785730476011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"28fbf17f-29fd-4873-af5d-269af03fe500"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730476012,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":36,"time":1785730476012,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} +{"type":"hook/result","seq":37,"time":1785730476020,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":7.691791999999964}} +{"type":"agent/inbox/spliced","seq":38,"time":1785498823389,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"1e17962a-bae0-4806-aa40-d4b396ecc336"}]}} +{"type":"agent/inbox/spliced","seq":39,"time":1785730476020,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":40,"time":1785730476028,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":41,"time":1785730476028,"data":{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"1e17962a-bae0-4806-aa40-d4b396ecc336"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":42,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":43,"time0":1784522154898,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,27,2],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]}} +{"type":"assistant/chunk","seq":61,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":62,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":63,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":64,"time":1785498823402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"type":"assistant/chunk","seq":65,"time":1785498823402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":66,"time":1785498823402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":67,"time":1785730476033,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":68,"time":1785730476033,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a862e81-3a46-49e3-b620-26f5ad4567e9"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1785730476033,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":70,"time":1785730476033,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} +{"type":"hook/result","seq":71,"time":1785730476036,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":2.646165999999994}} +{"type":"turn/end","seq":72,"time":1785730476036,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index 65f4dfbb3c..471b14d87e 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -1,26 +1,28 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"d0811ac0-36dc-4be4-ac75-69c114c3c7ba"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the lsp tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464639357,"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"},"role":"user","id":"2c4f0bc4-2a2c-402e-8d4f-c879d9fd9774"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464639357,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464639357,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487590335,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464639359,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487590336,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487590336,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"72f48996-b625-49ed-b102-2f96a0efa342"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487590337,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} -{"type":"tool/result","seq":14,"time":1785487590392,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_lsp_definition"},"content":[{"type":"tool-result","toolCallId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false}],"role":"user","id":"668f292a-c8f0-4166-86fa-fe96ee074385"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487590392,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487590400,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464639401,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1785487590401,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785487590401,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"a8b7a04d-5798-4e10-9b18-23dc381959dc"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785487590401,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785487590401,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498774978,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"d50783a4-e1dd-4d27-8aaf-fa854ffa5560"}]}} +{"type":"turn/start","seq":1,"time":1785821380423,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821380423,"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":1785498775018,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"d50783a4-e1dd-4d27-8aaf-fa854ffa5560"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730428059,"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":"63d79744-f179-4840-8278-b1ec07d25158"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730428059,"data":{"title":"Use the lsp tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498775021,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730428060,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"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":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498775022,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730428060,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730428061,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"31ac0375-d810-4f3b-acdd-fca8a41f7c8b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730428061,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"tool/result","seq":16,"time":1785730428097,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_lsp_definition"},"content":[{"type":"tool-result","toolCallId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false}],"role":"user","id":"7a227ee4-85a1-441d-8d26-2df72d164108"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730428097,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730428108,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498775076,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1785730428108,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730428108,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"94b551d6-7dc5-41fb-b898-42e8f44bfe4e"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730428108,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730428108,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json new file mode 100644 index 0000000000..f75309e4e1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl new file mode 100644 index 0000000000..f9e30bb24d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -0,0 +1,51 @@ +{"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":1785304900000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785916902430,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop."}],"source":{"kind":"user"},"role":"user","id":"2d2f8e7a-f08a-464d-8e94-048d1d95717e"}]}} +{"type":"turn/start","seq":1,"time":1785916902430,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785916902430,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785916902458,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785916902458,"data":{"content":[{"type":"text","text":"Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop."}],"source":{"kind":"user"},"role":"user","id":"2d2f8e7a-f08a-464d-8e94-048d1d95717e"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785916902459,"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\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"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":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"de3778e7-e47a-4d34-a004-ecf43da3c9db"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785916902459,"data":{"title":"Run true once with bash","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785916902460,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785916902460,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785304900008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1785304900009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-foreground","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1785304900010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785304900011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":13,"time":1785916902468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785916902468,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d588acd6-d0ab-43c5-9e18-67fe3f625e48"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785916902469,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} +{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785916902487,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785916902496,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1785304900019,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-background","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1785304900020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1785825343587,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":1785916902500,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785916902500,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b8ff9c32-71e9-46e3-a30d-60c9c0a99eb9"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785916902500,"data":{"turn":1,"step":2,"callId":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":26,"time":1785916902508,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"missing-runner-background"},"content":[{"type":"tool-result","toolCallId":"missing-runner-background","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"a40cf397-5842-4c09-a6b8-f831eb84827c"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785916902508,"data":{"turn":1,"step":2}} +{"type":"agent/inbox/spliced","seq":28,"time":1785916902508,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background task bash-1 (bash: true) finished [status: killed, killed before exit]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks","form":"notice","summary":"bash true [status: killed, killed before exit]"},"role":"user","id":"989e3c2b-5b21-4694-83d5-6cddac55ce0e"}]}} +{"type":"step/start","seq":29,"time":1785916902519,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":30,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":31,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-output","name":"task_output","argumentsDelta":"{\"task_id\":\"bash-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":32,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":33,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":34,"time":1785916902524,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":35,"time":1785916902524,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b5e176c7-fe2f-4b73-855d-416a48326392"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"tool/call","seq":36,"time":1785916902524,"data":{"turn":1,"step":3,"callId":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}} +{"type":"tool/result","seq":37,"time":1785916902532,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"missing-runner-output"},"content":[{"type":"tool-result","toolCallId":"missing-runner-output","content":[{"type":"text","text":"[stderr]\nspawn failed: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]\n[status: killed, killed before exit]"}],"isError":false}],"role":"user","id":"ac65952f-f6e9-459e-a653-87022fe03d60"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785916902532,"data":{"turn":1,"step":3}} +{"type":"agent/inbox/spliced","seq":39,"time":1785916902532,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":40,"time":1785916902542,"data":{"turn":1,"step":4}} +{"type":"user/message","seq":41,"time":1785916902542,"data":{"content":[{"type":"text","text":"background task bash-1 (bash: true) finished [status: killed, killed before exit]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks","form":"notice","summary":"bash true [status: killed, killed before exit]"},"role":"user","id":"989e3c2b-5b21-4694-83d5-6cddac55ce0e"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":42,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":43,"time":1785825343628,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"RUNNER_FAILURES_SURFACED"}}} +{"type":"assistant/chunk","seq":44,"time":1785916902550,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"type":"assistant/chunk","seq":45,"time":1785916902550,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":46,"time":1785916902550,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":47,"time":1785916902550,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a791acd-5f77-4ce4-ae02-572f4edfba0d"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"step/end","seq":48,"time":1785916902550,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":49,"time":1785916902550,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl new file mode 100644 index 0000000000..c7df2372dc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 484bae3680..46aac90caa 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,34 +1,38 @@ {"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"fa318cef-e21c-481a-887c-61d968ac6455"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352113765,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464647583,"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"},"role":"user","id":"98b180ae-d285-4068-8f76-c3133c921bef"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464647583,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464647584,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487603588,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352114542,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352114570,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,1,28,1,1,0,0,1,24,1,29,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]}} -{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} -{"type":"assistant/chunk","seq":29,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} -{"type":"assistant/chunk","seq":30,"time":1785464647595,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":31,"time":1785487603597,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785487603598,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"af1ea1b2-faa7-44f7-a5e7-2114ae30a557"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785487603598,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1785487603598,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":35,"time":1785487603598,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":36,"time":1785487603598,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"340a6fe2-1ea2-4f60-ab88-81e2c2307cf5"},"surfaceOp":"append"} -{"type":"step/start","seq":37,"time":1785487603607,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":38,"time":1783352115465,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":39,"time0":1783352115492,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,28,0,0,31,0,0,0,0,28,0,0,0,29,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]}} -{"type":"assistant/chunk","seq":57,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} -{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} -{"type":"assistant/chunk","seq":61,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} -{"type":"assistant/chunk","seq":62,"time":1785464647613,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":63,"time":1785487603612,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":1785487603612,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"556c57e8-48e4-4115-8af5-6ff5a3c9fee3"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487603612,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":66,"time":1785487603612,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498785982,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"4d8893f0-f22d-4e43-ac31-f5e7afbda565"}]}} +{"type":"turn/start","seq":1,"time":1785821395167,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821395167,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352113767,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498786007,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"4d8893f0-f22d-4e43-ac31-f5e7afbda565"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730439011,"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":"92ebc873-c6cf-4d0f-a30c-7ae0739d1007"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730439011,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498786009,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730439012,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352114542,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352114570,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,1,28,1,1,0,0,1,24,1,29,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]}} +{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":29,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":30,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} +{"type":"assistant/chunk","seq":31,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} +{"type":"assistant/chunk","seq":32,"time":1785498786018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":33,"time":1785730439022,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785730439022,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4ce3ae64-c2c0-407e-8aa9-46b65ecb0145"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730439022,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1785730439022,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":37,"time":1785498786019,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"99ed2338-f25f-47c5-b2d9-17f9f73f90f8"}]}} +{"type":"turn/start","seq":38,"time":1785821395209,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":39,"time":1785821395209,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":40,"time":1785730439033,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":41,"time":1785730439033,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"99ed2338-f25f-47c5-b2d9-17f9f73f90f8"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":42,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":43,"time0":1783352115493,"data":{"turn":2,"step":1,"index":0,"dt":[0,28,0,0,31,0,0,0,0,28,0,0,0,29,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]}} +{"type":"assistant/chunk","seq":61,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":62,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} +{"type":"assistant/chunk","seq":63,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":64,"time":1785498786031,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"type":"assistant/chunk","seq":65,"time":1785498786032,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} +{"type":"assistant/chunk","seq":66,"time":1785498786032,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":67,"time":1785730439038,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":68,"time":1785730439038,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"62c5b1a1-dfbb-4b31-af28-346d1ad87333"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1785730439038,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":70,"time":1785730439038,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl index f24cbd8d7b..d334ef777e 100644 --- a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl +++ b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"686d91c6-7880-4506-b887-11e1233815ee"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464664893,"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"},"role":"user","id":"32a58f8b-d4f0-4aed-ac97-cd21d1d986d0"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464664894,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464664894,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487630468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352166048,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352166075,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,1,0,0,28,0,27,0,58,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} -{"type":"assistant/chunk","seq":25,"time":1783352166250,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":26,"time0":1783352166250,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31,59,0],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} -{"type":"assistant/chunk","seq":51,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":52,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785464664905,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":54,"time":1785487630478,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785487630478,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"903bc7b0-7685-4c62-88f8-805322a4b76f"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785487630478,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} -{"type":"hook/invoked","seq":57,"time":1785487630478,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":58,"time":1785487630483,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":4.245374999999967}} -{"type":"tool/result","seq":59,"time":1785487630484,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_JliP571Bh0QQ8QExbSPk0080"},"content":[{"type":"tool-result","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true}],"role":"user","id":"e5212e56-9d5d-41fb-96d6-0b4c38d85be1"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":60,"time":1785487630484,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":61,"time":1785487630490,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":62,"time":1783352167440,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":63,"time0":1783352167469,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}} -{"type":"assistant/chunk","seq":84,"time":1783352167643,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":85,"time0":1783352167672,"data":{"turn":1,"step":2,"index":1,"dt":[29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1,0,0],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}} -{"type":"assistant/chunk","seq":115,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} -{"type":"assistant/chunk","seq":116,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} -{"type":"assistant/chunk","seq":117,"time":1785464664923,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":118,"time":1785487630496,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":119,"time":1785487630497,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0448ea8e-f773-4138-bb08-450aca04a0b4"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118],"surfaceOp":"append"} -{"type":"step/end","seq":120,"time":1785487630497,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":121,"time":1785487630497,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498765336,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"a207bd9d-9312-46ed-baaf-7a07a6f08ae8"}]}} +{"type":"turn/start","seq":1,"time":1785821364567,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821364567,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352165198,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498765364,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"a207bd9d-9312-46ed-baaf-7a07a6f08ae8"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730418683,"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":"1c954f81-4e70-4e28-bf11-5f8424f09391"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730418683,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498765365,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730418684,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352166048,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352166075,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,28,0,1,0,0,28,0,27,0,58,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":27,"time":1783352166250,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":28,"time0":1783352166250,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31,59,0],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785498765375,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":56,"time":1785730418696,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785730418696,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"658eb4a4-7462-43d8-91eb-13d09363db20"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785730418696,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1785730418697,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1785730418702,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":4.435375000000022}} +{"type":"tool/result","seq":61,"time":1785730418702,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_JliP571Bh0QQ8QExbSPk0080"},"content":[{"type":"tool-result","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true}],"role":"user","id":"85f289f4-cb3c-468e-bbad-e66fefe2346f"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1785730418702,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1785730418710,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783352167440,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":65,"time0":1783352167469,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}} +{"type":"assistant/chunk","seq":86,"time":1783352167643,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":87,"time0":1783352167672,"data":{"turn":1,"step":2,"index":1,"dt":[29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1,0,0],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}} +{"type":"assistant/chunk","seq":117,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} +{"type":"assistant/chunk","seq":118,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} +{"type":"assistant/chunk","seq":119,"time":1785498765392,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":120,"time":1785730418716,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":121,"time":1785730418716,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0bea7b77-242e-4399-bd10-90324a37fff0"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120],"surfaceOp":"append"} +{"type":"step/end","seq":122,"time":1785730418716,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":123,"time":1785730418717,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl index 3c466f5d2f..295fe91ed9 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -1,31 +1,33 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"982313f2-a4bb-40d6-ac24-24e600b0549c"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464633242,"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"},"role":"user","id":"ccacafd3-0871-406f-becc-d8dd4f25aadd"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464633242,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464633243,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487580986,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785464633252,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785487580996,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785487580996,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"113a5fe8-a119-4831-aa02-b79fc16826a1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785464633253,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} -{"type":"tool/call","seq":17,"time":1785487580997,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} -{"type":"tool/result","seq":18,"time":1785464633265,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"7e9f3cc3-3239-4440-967d-72a5c9e437e3"},"meta":{"path":"{{cwd}}/a.txt","offset":1,"lines":[{"number":1,"text":"alpha"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"tool/result","seq":19,"time":1785487581009,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"981e91d9-f176-42f2-a25c-8e2340e4fd93"},"meta":{"path":"{{cwd}}/b.txt","offset":1,"lines":[{"number":1,"text":"beta"}],"totalLines":1}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":1785487581009,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":21,"time":1785487581015,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":25,"time":1785464633277,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":26,"time":1785487581020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":27,"time":1785487581020,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"80c2158b-1c08-4c6c-a279-2eda046d8d50"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} -{"type":"step/end","seq":28,"time":1785487581020,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":29,"time":1785487581020,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498766477,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"e306a97e-4da2-4b50-bec4-90ede1237df4"}]}} +{"type":"turn/start","seq":1,"time":1785821366930,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821366930,"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":1785498766502,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"e306a97e-4da2-4b50-bec4-90ede1237df4"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730419890,"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":"02b21476-4349-49c1-a1b8-91d80c27ef0d"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730419890,"data":{"title":"Use the read tool twice","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498766504,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730419891,"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":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1785498766513,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1785730419899,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1785730419899,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2de71b6c-3820-4fc4-99c9-0a2c8a1f8e9b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1785498766514,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} +{"type":"tool/call","seq":19,"time":1785730419900,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} +{"type":"tool/result","seq":20,"time":1785498766528,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"418e6b3d-9166-432a-8e56-839a87079295"},"meta":{"path":"{{cwd}}/a.txt","offset":1,"lines":[{"number":1,"text":"alpha"}],"totalLines":1}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","seq":21,"time":1785730419909,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"f92c11c2-0d44-4a61-a4f0-913dcc765e77"},"meta":{"path":"{{cwd}}/b.txt","offset":1,"lines":[{"number":1,"text":"beta"}],"totalLines":1}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":1785730419909,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":23,"time":1785730419918,"data":{"turn":1,"step":2}} +{"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":"DONE"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":27,"time":1785498766538,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":28,"time":1785730419922,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1785730419922,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fdbb9418-bd61-4ec5-9bb9-fa73f632b242"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1785730419922,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":1785730419922,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json new file mode 100644 index 0000000000..57f5effa73 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl new file mode 100644 index 0000000000..7b73001a01 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1785218500000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785916901382,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"8a81cb32-8acc-4929-bb63-ec02adea20df"}]}} +{"type":"turn/start","seq":1,"time":1785916901382,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785916901383,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785916901409,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785916901409,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"8a81cb32-8acc-4929-bb63-ec02adea20df"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785916901409,"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\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"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":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"b3b13d6d-dcef-47cb-bbb3-26229c44792c"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785916901409,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785916901410,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785916901410,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785218500009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1785218500010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"partial-landlock-call","name":"bash","argumentsDelta":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1785218500011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785218500012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":13,"time":1785916901418,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785916901419,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ae5e03f5-0d67-4971-bd8c-e0a34ca6802b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785916901419,"data":{"turn":1,"step":1,"callId":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}} +{"type":"tool/result","seq":16,"time":1785916901439,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"partial-landlock-call"},"content":[{"type":"tool-result","toolCallId":"partial-landlock-call","content":[{"type":"text","text":"[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"}],"isError":false}],"role":"user","id":"37de4d5e-931a-4ffe-bfbd-b701c17dce3c"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785916901439,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785916901451,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1785218500019,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1785218500020,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_EXIT_PRESERVED"}}} +{"type":"assistant/chunk","seq":21,"time":1785218500021,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"type":"assistant/chunk","seq":22,"time":1785218500022,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":1785916901455,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785916901456,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_EXIT_PRESERVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"86d2d3b2-b8e1-400e-aa27-06749c572f66"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785916901456,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785916901456,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl new file mode 100644 index 0000000000..98a85f5207 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl index 3acc16636f..f9d4ed2242 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,76 +1,78 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"5b4096da-09f8-4424-82a2-87adc56a9830"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464635920,"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"},"role":"user","id":"b928f152-1813-43e2-8f54-276ab004eb55"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464635920,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464635921,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487585125,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"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":9,"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":10,"time":1785464635930,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487585133,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487585134,"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-pro"},"id":"2e7ce860-0ee3-45c5-91a9-1bd810d7dd6e"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487585134,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":14,"time":1785487585141,"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":"1a9fb844-2313-4c15-a1b2-4de1692b45f0"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487585141,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487585149,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"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":19,"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":20,"time":1785464635953,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785487585153,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487585153,"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-pro"},"id":"d194ed34-9de5-4cfa-bec3-6c6722bd9b72"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487585154,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":24,"time":1785487585161,"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":"e90c80e2-2ffd-47e3-ac6b-c8b75ecb6520"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785487585162,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785487585168,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"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":29,"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":30,"time":1785464635975,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":31,"time":1785487585173,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785487585173,"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-pro"},"id":"d8db91ef-187f-4123-8418-f03f4b71a9db"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785487585173,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":34,"time":1785487585180,"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":"e2fcdef1-f155-43e8-8380-06cb1ff18a3d"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785487585180,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":36,"time":1785487585187,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"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":39,"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":40,"time":1785464635996,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":41,"time":1785487585192,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":42,"time":1785487585192,"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-pro"},"id":"a8827428-8fd0-49f2-917f-30ecf960a709"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} -{"type":"tool/call","seq":43,"time":1785487585193,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":44,"time":1785487585199,"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":"08b9d5d4-e93a-4646-85e4-29fb24c05e20"}},"sourceEventSeqs":[43],"surfaceOp":"append"} -{"type":"step/end","seq":45,"time":1785487585199,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":46,"time":1785487585206,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"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":49,"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":50,"time":1785464636023,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":51,"time":1785487585211,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785487585211,"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-pro"},"id":"bee8b55e-8e62-421e-8c37-8670b00cc14f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785487585211,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":54,"time":1785487585218,"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":"8443772a-e1dd-4065-99c4-1ae024f047d8"}},"sourceEventSeqs":[53],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1785487585218,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":56,"time":1785487585225,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":58,"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":59,"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":60,"time":1785464636044,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":61,"time":1785487585230,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":62,"time":1785487585230,"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-pro"},"id":"562a69e6-51b2-468c-9241-c8a6d1d3fa9c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} -{"type":"tool/call","seq":63,"time":1785487585230,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":64,"time":1785487585238,"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":"3bc30795-4480-4408-bc4f-363f379febb9"}},"sourceEventSeqs":[63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487585238,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":66,"time":1785487585245,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":70,"time":1785464636064,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":71,"time":1785487585250,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":72,"time":1785487585250,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"7ef87d2e-b81c-493e-8049-2106efd5e8f8"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[67,68,69,70,71],"surfaceOp":"append"} -{"type":"step/end","seq":73,"time":1785487585250,"data":{"turn":1,"step":7}} -{"type":"turn/end","seq":74,"time":1785487585250,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498770125,"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":"96ac9845-3961-4010-8ee5-d9e5aff18b42"}]}} +{"type":"turn/start","seq":1,"time":1785821373074,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821373074,"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":1785498770152,"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":"96ac9845-3961-4010-8ee5-d9e5aff18b42"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730423409,"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":"f7ef1bc0-f4ec-4d3e-b198-399ee1cec46f"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730423409,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498770153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730423410,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"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":1785498770162,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730423419,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730423419,"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-pro"},"id":"e056cd02-3559-4248-9084-53ab36bdfcc0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730423419,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} +{"type":"tool/result","seq":16,"time":1785730423429,"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":"913adb46-de7b-43c1-aafa-20c418191d15"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730423429,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730423439,"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":1785498770184,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1785730423444,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730423444,"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-pro"},"id":"15be1b35-69d6-43bf-85f1-c64587b12e9b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730423445,"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":1785730423452,"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":"02d9fb03-cbb3-410b-bb2d-60cf498d2ed0"},"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":1785730423452,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1785730423461,"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":1785498770206,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":33,"time":1785730423464,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1785730423464,"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-pro"},"id":"2eafd705-ff32-4d46-8797-e2536f28bb31"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1785730423464,"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":1785730423472,"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":"e21fc216-a68c-4f29-88a8-e8832a0cbe67"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1785730423472,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1785730423481,"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":1785498770225,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":43,"time":1785730423485,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1785730423485,"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-pro"},"id":"9889f18a-c553-40ec-8fd4-1c3c5b519316"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1785730423486,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} +{"type":"tool/result","seq":46,"time":1785730423493,"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":"93f5ffa7-9b28-4718-9404-3677b1e2b17d"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1785730423493,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1785730423503,"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":1785498770243,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":53,"time":1785730423507,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":54,"time":1785730423507,"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-pro"},"id":"7db7089b-ba67-4959-a0d8-a76f6ffc6fdc"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"tool/call","seq":55,"time":1785730423507,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} +{"type":"tool/result","seq":56,"time":1785730423516,"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":"7d01c0f6-e5b8-4989-84e8-f7fa0c9a168b"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"step/end","seq":57,"time":1785730423516,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":58,"time":1785730423526,"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":1785498770261,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":63,"time":1785730423530,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1785730423530,"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-pro"},"id":"db82030f-ba17-4b44-b818-21a982da8dfb"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730423530,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} +{"type":"tool/result","seq":66,"time":1785730423537,"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":"2e2fee60-7450-4c32-819a-a32cbd2ef1aa"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730423537,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":68,"time":1785730423546,"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":1785498770281,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":73,"time":1785730423550,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":74,"time":1785730423550,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"1d660de9-1864-4c09-82d7-e3ac9da8c7fe"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"step/end","seq":75,"time":1785730423551,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":76,"time":1785730423551,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..09a6a7e7ed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}} +{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,0,0,0,0,275,0,1,0,0,0,0,0,0,0,29],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"",", ","\"","description","\"",": ","\"","Write"," P","WS","H","_OK"," to"," console","\"","}"]}} +{"type":"assistant/chunk","seq":67,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":68,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}}} +{"type":"assistant/chunk","seq":69,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":70,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":71,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70],"surfaceOp":"append"} +{"type":"tool/call","seq":72,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}} +{"type":"tool/result","seq":73,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[72],"surfaceOp":"append"} +{"type":"step/end","seq":74,"time":1785898456933,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":75,"time":1785898456939,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":76,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":77,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":102,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":103,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":104,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":105,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":106,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":107,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":108,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":109,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"step/end","seq":110,"time":1785898456944,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":111,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..f354648c41 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,7 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a concise snapshot agent working in {{cwd}}. + +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..611de722e3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,90 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell 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\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index 21951c43c5..800e6f9b01 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -1,73 +1,79 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0a8a60c7-eece-48d5-ac75-fb39c9d79c56"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Write the todo list 'watch","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464649934,"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"},"role":"user","id":"60a180b9-4aea-4d04-87aa-9e19cde4d6de"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464649934,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464649935,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487607439,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_1","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464649944,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487607449,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487607449,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3792dc65-56ac-450b-9857-315075a72fbf"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487607449,"data":{"turn":1,"step":1,"callId":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"type":"todo/write","seq":14,"time":1785487607457,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} -{"type":"tool/result","seq":15,"time":1785487607458,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_1"},"content":[{"type":"tool-result","toolCallId":"call_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"c42bf233-970c-414e-9911-c7f805fe9462"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785487607458,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1785487607467,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_2","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} -{"type":"assistant/chunk","seq":21,"time":1785464649966,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":22,"time":1785487607472,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785487607472,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0f043df3-6b8d-4e69-b847-773bb7ccdcee"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"tool/call","seq":24,"time":1785487607473,"data":{"turn":1,"step":2,"callId":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"type":"todo/write","seq":25,"time":1785487607480,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} -{"type":"tool/result","seq":26,"time":1785487607480,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_2"},"content":[{"type":"tool-result","toolCallId":"call_2","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"a779b4c8-5345-424e-964f-f79252a41a5b"}},"sourceEventSeqs":[24],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785487607480,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":28,"time":1785487607487,"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":"call_3","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} -{"type":"assistant/chunk","seq":32,"time":1785464649986,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":33,"time":1785487607491,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785487607491,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"08cf5ce1-1c2e-44fc-b4ce-8f120f1fdab5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"tool/call","seq":35,"time":1785487607491,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"type":"todo/write","seq":36,"time":1785487607499,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} -{"type":"tool/result","seq":37,"time":1785487607499,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_3"},"content":[{"type":"tool-result","toolCallId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"837f2606-2c17-44b5-b795-406f48efab66"}},"sourceEventSeqs":[35],"surfaceOp":"append"} -{"type":"user/message","seq":38,"time":1785487607499,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"},"role":"user","id":"6d850e11-d21a-4688-b6ca-abcf83f5c536"},"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785487607499,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":40,"time":1785487607506,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_4","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} -{"type":"assistant/chunk","seq":44,"time":1785464650008,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":45,"time":1785487607511,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":46,"time":1785487607511,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1dddb44-612c-460a-81b0-df803ff888b9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} -{"type":"tool/call","seq":47,"time":1785487607511,"data":{"turn":1,"step":4,"callId":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"type":"todo/write","seq":48,"time":1785487607518,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} -{"type":"tool/result","seq":49,"time":1785487607518,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_4"},"content":[{"type":"tool-result","toolCallId":"call_4","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"a4f81218-4c58-4768-913d-1373ba7853b4"}},"sourceEventSeqs":[47],"surfaceOp":"append"} -{"type":"step/end","seq":50,"time":1785487607518,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":51,"time":1785487607526,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_5","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} -{"type":"assistant/chunk","seq":55,"time":1785464650027,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":56,"time":1785487607531,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":57,"time":1785487607531,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9031c8b-a4e3-44e5-a26d-a7521ca90ab0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"} -{"type":"tool/call","seq":58,"time":1785487607531,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"type":"todo/write","seq":59,"time":1785487607538,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} -{"type":"tool/result","seq":60,"time":1785487607538,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_5"},"content":[{"type":"tool-result","toolCallId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"5f2668c7-6161-45f5-94e0-3dee26391efa"}},"sourceEventSeqs":[58],"surfaceOp":"append"} -{"type":"user/message","seq":61,"time":1785487607538,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"},"role":"user","id":"55ed1387-137f-4fe9-b149-17c2d781874c"},"surfaceOp":"append"} -{"type":"step/end","seq":62,"time":1785487607538,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":63,"time":1785487607545,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"DONE."}}} -{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE."}}}} -{"type":"assistant/chunk","seq":67,"time":1785464650047,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":68,"time":1785487607550,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":69,"time":1785487607550,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"DONE."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5bee849d-42f5-42d5-a125-454a886ed774"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"} -{"type":"step/end","seq":70,"time":1785487607550,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":71,"time":1785487607551,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498789124,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f92afb51-ac61-47d2-b0fb-ee55cc744838"}]}} +{"type":"turn/start","seq":1,"time":1785821399027,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821399027,"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":1785498789151,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f92afb51-ac61-47d2-b0fb-ee55cc744838"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730442276,"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":"f9ec98a9-17c2-418e-9982-b8b3e2f8a17d"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730442276,"data":{"title":"Write the todo list 'watch","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498789152,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730442277,"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":"call_1","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498789161,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730442285,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730442286,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00a7c9b0-f148-40a5-ae5b-4209e4b03b1b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730442286,"data":{"turn":1,"step":1,"callId":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":16,"time":1785730442294,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} +{"type":"tool/result","seq":17,"time":1785730442295,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_1"},"content":[{"type":"tool-result","toolCallId":"call_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"724f60cf-a6ae-44a8-8414-65097f95f24c"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730442295,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1785730442304,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_2","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":23,"time":1785498789182,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1785730442308,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":25,"time":1785730442308,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"51d7bb7a-2cd7-46dd-9805-827a0f4967bc"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"tool/call","seq":26,"time":1785730442308,"data":{"turn":1,"step":2,"callId":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":27,"time":1785730442316,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} +{"type":"tool/result","seq":28,"time":1785730442316,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_2"},"content":[{"type":"tool-result","toolCallId":"call_2","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"8d32ba45-05e7-4542-a79f-d38bd0be1940"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1785730442316,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":1785730442324,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_3","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":34,"time":1785498789200,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":35,"time":1785730442328,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":1785730442328,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6889b3aa-8f9c-47a5-8073-ea9ff88928e6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":1785730442328,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":38,"time":1785730442335,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} +{"type":"tool/result","seq":39,"time":1785730442335,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_3"},"content":[{"type":"tool-result","toolCallId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"779c894c-9e9f-4c8e-a073-36d32b421b0f"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":40,"time":1785730442335,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard","form":"notice","summary":"todo_write × 3"},"role":"user","id":"1dee8d17-2cdd-4f76-8330-709191cf8cbb"}]}} +{"type":"step/end","seq":41,"time":1785730442335,"data":{"turn":1,"step":3}} +{"type":"agent/inbox/spliced","seq":42,"time":1785730442335,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":43,"time":1785730442344,"data":{"turn":1,"step":4}} +{"type":"user/message","seq":44,"time":1785730442344,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard","form":"notice","summary":"todo_write × 3"},"role":"user","id":"1dee8d17-2cdd-4f76-8330-709191cf8cbb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1785498789219,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_4","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"type":"assistant/chunk","seq":47,"time":1785498789219,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":48,"time":1785498789219,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":1785730442349,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1785730442349,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68a60126-1b86-4063-8f56-a20fab8520b0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1785730442349,"data":{"turn":1,"step":4,"callId":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":52,"time":1785730442356,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} +{"type":"tool/result","seq":53,"time":1785730442356,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_4"},"content":[{"type":"tool-result","toolCallId":"call_4","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"19da4151-613e-41b0-9932-16c19cbc0614"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1785730442356,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":55,"time":1785730442364,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":57,"time":1785498789237,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_5","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"type":"assistant/chunk","seq":58,"time":1785498789237,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":59,"time":1785498789237,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":60,"time":1785730442368,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1785730442368,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7f3f99fa-2ad7-4cc4-afa8-78d0e28979e4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"tool/call","seq":62,"time":1785730442368,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":63,"time":1785730442376,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} +{"type":"tool/result","seq":64,"time":1785730442376,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_5"},"content":[{"type":"tool-result","toolCallId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"fa3d2366-ffd8-4f75-833d-e4193c7c9749"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":65,"time":1785730442376,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard","form":"notice","summary":"todo_write × 5"},"role":"user","id":"4ca50ec2-4e31-43c2-bc10-f4bdaa678127"}]}} +{"type":"step/end","seq":66,"time":1785730442376,"data":{"turn":1,"step":5}} +{"type":"agent/inbox/spliced","seq":67,"time":1785730442376,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":68,"time":1785730442384,"data":{"turn":1,"step":6}} +{"type":"user/message","seq":69,"time":1785730442384,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard","form":"notice","summary":"todo_write × 5"},"role":"user","id":"4ca50ec2-4e31-43c2-bc10-f4bdaa678127"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":70,"time":1785498789257,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":71,"time":1785498789257,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"DONE."}}} +{"type":"assistant/chunk","seq":72,"time":1785498789257,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE."}}}} +{"type":"assistant/chunk","seq":73,"time":1785498789257,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":74,"time":1785730442389,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":75,"time":1785730442389,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"DONE."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48236fa5-4888-4e27-9e17-05bc246ea622"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[70,71,72,73,74],"surfaceOp":"append"} +{"type":"step/end","seq":76,"time":1785730442389,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":77,"time":1785730442389,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 6ca0698024..9ba17ca2ee 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -1,36 +1,38 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Read request event 5 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7129c99d-22c8-4643-b958-dc493099dbc6"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 5 with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464635081,"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"},"role":"user","id":"195c46d7-2148-422e-bc06-416a33d13cad"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464635081,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464635082,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487583775,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":5}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464635091,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487583784,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487583784,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"93e52142-e666-42f0-a2d8-97ac4cce08d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487583784,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}} -{"type":"tool/result","seq":14,"time":1785487583793,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_query_spill"},"content":[{"type":"tool-result","toolCallId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 5 with\nTarget event seq 5:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 5,\n \"time\": 1785487583775,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek-official\",\n \"model\": \"deepseek-v4-flash\"\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 35794 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-0a508d3a5c8b/adedf3ca051a-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false}],"role":"user","id":"cc4aac80-24a4-4a27-99d9-fbcafc812d63"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487583794,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487583801,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464635114,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785487583806,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487583806,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"98a8a3f7-a00d-485e-b1b7-128fdfa9f662"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487583807,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} -{"type":"tool/result","seq":24,"time":1785487583831,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_verify_session_query_spill"},"content":[{"type":"tool-result","toolCallId":"call_verify_session_query_spill","content":[{"type":"text","text":"SPILL_CANONICAL_OK\n"}],"isError":false}],"role":"user","id":"06313924-d62f-4fa3-9e39-83c7ee37bfc4"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785487583831,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785487583838,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":30,"time":1785464635152,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":31,"time":1785487583843,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785487583843,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"38628c04-99d6-40e6-ac32-b74be9a40234"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785487583844,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":34,"time":1785487583844,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498768943,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read request event 5 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"05ed182c-4c88-4019-912e-518ed6e431ba"}]}} +{"type":"turn/start","seq":1,"time":1785821371103,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821371103,"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":1785498768995,"data":{"content":[{"type":"text","text":"Read request event 5 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"05ed182c-4c88-4019-912e-518ed6e431ba"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730422266,"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":"82025f74-4ec2-4ac7-a90b-5eb18f184abb"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730422266,"data":{"title":"Read request event 5 with","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498768997,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730422267,"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":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":5}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498769006,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730422276,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730422276,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a4ee27a4-32b2-40d1-aeac-6a8bc8fcc2de"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730422276,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":5}"}} +{"type":"tool/result","seq":16,"time":1785730422286,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_query_spill"},"content":[{"type":"tool-result","toolCallId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 5 with\nTarget event seq 5:\n```json\n{\n \"type\": \"user/message\",\n \"seq\": 5,\n \"time\": 1785987646184,\n \"data\": {\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Current runtime context. This snapshot supersedes mpts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\"\n }\n ]\n },\n \"role\": \"user\",\n \"id\": \"985f57e7-e296-4210-af78-78a485f09894\"\n },\n \"surfaceOp\": \"append\"\n}\n```\n\n(Omitted 782 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-aa56455bb13a/dfff8c2b8a66-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false}],"role":"user","id":"8f96f03f-4fca-4c3a-ba34-ce891adde50f"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730422286,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730422296,"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":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498769028,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1785730422300,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730422301,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00965b8a-4e8a-40e4-9418-fdb044859156"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730422301,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} +{"type":"tool/result","seq":26,"time":1785730422323,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_verify_session_query_spill"},"content":[{"type":"tool-result","toolCallId":"call_verify_session_query_spill","content":[{"type":"text","text":"(no output)\n[exit code: 1]"}],"isError":false}],"role":"user","id":"e43faec5-4511-48d9-8021-c56b7f7cb794"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730422323,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1785730422332,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":32,"time":1785498769063,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":33,"time":1785730422336,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785730422337,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59890792-9e9c-4be8-b4f4-d25ff06855d2"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730422337,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":36,"time":1785730422337,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl index 450a1aea97..322d931e1a 100644 --- a/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl @@ -1,26 +1,28 @@ {"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"cwd":"/Users/cty/acp-snap-cwd-MABAjO","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784567324138,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784821266392,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"8b7533eb-79db-459d-8ab5-e17a69aa99e0"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784821266392,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464678705,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"/Users/cty/acp-snap-cwd-MABAjO\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"ff65d0ad-f029-4d13-8d3d-10cc2f88cf3a"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464678705,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464678705,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487651556,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784821266418,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1784821266418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464678714,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487651565,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487651565,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c48b5704-3948-4039-a22b-63318599ac33"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487651565,"data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} -{"type":"tool/result","seq":14,"time":1785487651580,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"<path>/Users/cty/acp-snap-cwd-MABAjO/session-root.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"4b79567c-44ca-4a15-80f4-18c2d5c96d91"},"meta":{"diffs":[]}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487651580,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487651587,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1784821266442,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1784821266442,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":19,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464678741,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1785487651592,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785487651592,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e84ca7f0-1382-4088-bf7a-f919eaf7c418"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785487651592,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785487651592,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498831793,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"f7d05c95-98f0-44b5-9463-2449682817ff"}]}} +{"type":"turn/start","seq":1,"time":1785821448088,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821448088,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784821266397,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498831818,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"f7d05c95-98f0-44b5-9463-2449682817ff"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730483789,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"/Users/cty/acp-snap-cwd-MABAjO\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"/Users/cty/acp-snap-cwd-MABAjO\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"7855df4a-1a61-4d6c-bb03-84b80edb0075"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730483789,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498831819,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730483790,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784821266418,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1784821266418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498831828,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730483798,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730483799,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8d1a070d-5dce-4e7c-9a7c-dcde32b3d1df"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730483799,"data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} +{"type":"tool/result","seq":16,"time":1785730483813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"<path>/Users/cty/acp-snap-cwd-MABAjO/session-root.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"06269b5a-d051-4105-9caf-2d588025d07c"},"meta":{"diffs":[]}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730483813,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730483823,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1784821266442,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1784821266442,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498831855,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1785730483827,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730483827,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"87b694cc-1b3d-4b38-9d2c-1a902556327a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730483827,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730483828,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl index 6b2e05c891..0993d85ded 100644 --- a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -1,18 +1,20 @@ {"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"a343504e-776a-4c93-9fba-cfcf35af92d8"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464630804,"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"},"role":"user","id":"1b4b2fb6-489f-4ab7-b355-e3846932e8e2"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464630805,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464630805,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487577012,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"session/title-llm-request","seq":7,"time":1785487577014,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[1],"route":{"provider":"title-replay","model":"title-model"},"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\":1,\"text\":\"Reply with exactly TITLE_DONE. Do not use tools.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"289089a1-dc9c-4780-a957-391d427b04a9"}],"maxTokens":32}} -{"type":"assistant/chunk","seq":8,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"TITLE_DONE"}}} -{"type":"assistant/chunk","seq":10,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TITLE_DONE"}}}} -{"type":"assistant/chunk","seq":11,"time":1785464630814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":12,"time":1785487577022,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1785487577022,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"TITLE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3e93ff2c-0e8d-4918-aa5c-ffaf7ca1aa5c"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785487577022,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":15,"time":1785487577022,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/title","seq":16,"time":1785487577022,"data":{"title":"Late durable session title","messageSeqs":[1],"source":{"kind":"provider","provider":"session-title-first-message-llm","model":{"provider":"title-replay","model":"title-model"}}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498762928,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"07495f06-71ba-4146-b27c-de2cf46a60fb"}]}} +{"type":"turn/start","seq":1,"time":1785821360788,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821360788,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785222848199,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498762955,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"07495f06-71ba-4146-b27c-de2cf46a60fb"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730416395,"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":"d2f80db6-391b-4fe4-bfd8-744807253b12"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730416395,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498762958,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730416397,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title-llm-request","seq":9,"time":1785730416397,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[4],"route":{"provider":"title-replay","model":"title-model"},"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\":4,\"text\":\"Reply with exactly TITLE_DONE. Do not use tools.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"626a7388-f08d-4d7b-b6c1-51056828182e"}],"maxTokens":32}} +{"type":"assistant/chunk","seq":10,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"TITLE_DONE"}}} +{"type":"assistant/chunk","seq":12,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TITLE_DONE"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498762968,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":14,"time":1785730416406,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730416406,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"TITLE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2c014efb-65c8-4d17-aa95-b535f7f9ff64"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730416406,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730416406,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":18,"time":1785730416406,"data":{"title":"Late durable session title","messageSeqs":[4],"source":{"kind":"provider","provider":"session-title-first-message-llm","model":{"provider":"title-replay","model":"title-model"}}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 81703a533d..f30dc715cf 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2243ae1a-2d65-4f9c-a972-d360b8cc08aa"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903324926,"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"},"role":"user","id":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785464638477,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785464638478,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785487588943,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":9,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} -{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} -{"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} -{"type":"assistant/chunk","seq":14,"time":1785464638487,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":1785487588953,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":16,"time":1785487588953,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3981353-74b6-424b-a508-23bbedb6f8d0"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[8,9,10,11,12,13,14,15],"surfaceOp":"append"} -{"type":"tool/call","seq":17,"time":1785487588953,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":18,"time":1785487588963,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"snapshot-skill\">\n<skill_resources>\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n</skill_instructions>\n</skill_content>"}],"isError":false}],"role":"user","id":"1bd2b7b8-30ed-4b3f-b697-956689edc210"}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785487588963,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":20,"time":1785487588971,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":21,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":22,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} -{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} -{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} -{"type":"assistant/chunk","seq":26,"time":1784903324956,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":27,"time":1785464638508,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} -{"type":"assistant/chunk","seq":28,"time":1785487588975,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1785487588975,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04ed6540-94e9-4872-a649-fab92823dc1b"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[21,22,23,24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":1785487588975,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":31,"time":1785487588975,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498773710,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"}]}} +{"type":"turn/start","seq":1,"time":1785821378605,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821378605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785498773755,"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":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n</system-reminder>"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} +{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} +{"type":"assistant/chunk","seq":14,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} +{"type":"assistant/chunk","seq":15,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1785498773765,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} +{"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"snapshot-skill\">\n<skill_resources>\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n</skill_instructions>\n</skill_content>"}],"isError":false}],"role":"user","id":"1609c2f6-3bc5-4ade-95dd-29e7f7565987"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} +{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} +{"type":"assistant/chunk","seq":27,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} +{"type":"assistant/chunk","seq":28,"time":1784903324956,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":29,"time":1785498773786,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} +{"type":"assistant/chunk","seq":30,"time":1785730426852,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785730426853,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"abdbdc3b-06a3-4b5f-b807-15d6566154a0"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[23,24,25,26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785730426853,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":33,"time":1785730426853,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json index 9566755044..f1354bd76a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -9,6 +9,7 @@ { "op": "prompt", "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool." - } + }, + { "op": "waitForSubagentTurnEnd", "child": 1, "minimumTurn": 3 } ] } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index eaa16935e7..554de6f448 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,32 +1,37 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} -{"type":"turn/start","seq":2,"time":1785544945199,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":3,"time":1785544945199,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"cd28c816-821e-412c-bc7f-404bbb599641"},"surfaceOp":"append"} -{"type":"session/title","seq":4,"time":1785544945199,"data":{"title":"Reply with exactly the word","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":5,"time":1789000000005,"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"},"role":"user","id":"2ea12eb1-e86f-447a-8574-63f2d819c689"},"surfaceOp":"append"} -{"type":"step/start","seq":6,"time":1785544945227,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1785544945227,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785544945227,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":13,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1789000000013,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"99ab55a3-f42f-4816-8fff-3b3bcb15fa6b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1789000000014,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1789000000015,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":17,"time":1789000000016,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":18,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"70a11623-f9c9-43d1-bad6-9bf45d19dd90"},"surfaceOp":"append"} -{"type":"step/start","seq":19,"time":1789000000018,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":20,"time":1785394678743,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1789000000020,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":22,"time":1789000000021,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":23,"time":1789000000022,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":24,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785394678743,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"86c9fc2b-900b-4a84-9089-dd4b8ed3d2d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785394678743,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":27,"time":1785394678743,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":28,"time":1785394678756,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":29,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"c3a91e09-99fc-4303-92e4-d8e89cb767f4"},"surfaceOp":"append"} -{"type":"turn/end","seq":30,"time":1785545035946,"data":{"turn":3,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} +{"type":"agent/inbox/spliced","seq":2,"time":1785730451347,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} +{"type":"turn/start","seq":3,"time":1785821409024,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":4,"time":1785730917162,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} +{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} +{"type":"step/start","seq":7,"time":1785730917198,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":8,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} +{"type":"user/message","seq":9,"time":1785730917198,"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":"49acbc16-4d58-460e-8cc0-62838472dce6"},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":1785730917198,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":11,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":12,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":13,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":15,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":16,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":17,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730696668,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":21,"time":1785821409092,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":22,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":23,"time":1785730696682,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":24,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":25,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":27,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1785730696686,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":32,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":1785821409110,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":34,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"turn/end","seq":35,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 4d0602e8df..898d4b250c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,56 +1,58 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"9472efc7-dd29-439f-8387-9b2dee43cd33"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785544945178,"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"},"role":"user","id":"d28c0ecc-be25-4d19-9834-ad72889ddaa3"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785544945178,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785544945179,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785544945179,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785544945188,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3a4667fb-352d-4ee7-ab80-42cf1dd6fb35"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785544945188,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":14,"time":1785544945199,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3567aec2-7919-4813-a15d-c5e9021f6968"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785544945199,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785544945207,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} -{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785544945212,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"beccc09f-a7ad-4537-ba2d-756961723dd4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785544945212,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} -{"type":"tool/result","seq":24,"time":1785544945224,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"fd85eacb-71f4-4a33-a512-b2e0c3040f65"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785544945224,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785544945236,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} -{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} -{"type":"assistant/chunk","seq":30,"time":1785544945241,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":31,"time":1785544945242,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785544945242,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b3e3797-a438-4751-8328-430cb4dc8689"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785544945242,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} -{"type":"tool/result","seq":34,"time":1785544945255,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"cfb87156-ab7e-4641-a99b-245215621b90"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785544945255,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":36,"time":1785544945267,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} -{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} -{"type":"assistant/chunk","seq":40,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":41,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":42,"time":1785544945273,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"52f1ebed-7577-4007-a07a-00f6a603c2f0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} -{"type":"tool/call","seq":43,"time":1785544945273,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":44,"time":1785544945285,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"eaf26a9c-d339-4fa3-900a-9e47d23cccaf"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[43],"surfaceOp":"append"} -{"type":"step/end","seq":45,"time":1785544945285,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":46,"time":1785544945297,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":50,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":51,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":52,"time":1785544945303,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fe4182fd-2de4-4e8d-9770-cb221b2b416a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"step/end","seq":53,"time":1785544945303,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":54,"time":1785544945303,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730451297,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"}]}} +{"type":"turn/start","seq":1,"time":1785821408972,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821408972,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785730451327,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730451328,"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":"e7521889-28d8-4434-84b2-21ff0e044fe7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730451328,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785730451329,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730451329,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":11,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730451338,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730451338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a2c2f8b-66be-4860-9bbf-b83feb56009e"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730451338,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":16,"time":1785730451348,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"b54a0d61-4233-40f1-ab3a-3eb58e1b0c61"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730451348,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730451360,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} +{"type":"assistant/chunk","seq":21,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1785730451364,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730451364,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"398dea92-a100-4b2f-a9e1-72629def132d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730451365,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} +{"type":"tool/result","seq":26,"time":1785730451377,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"45b388fd-6a48-4a02-9f3b-1d642e797c71"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730451377,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1785730451390,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} +{"type":"assistant/chunk","seq":31,"time":1785544945241,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":1785544945242,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":33,"time":1785730451394,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1785730451394,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f56cec19-e761-4c77-9237-07d12d334275"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1785730451395,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} +{"type":"tool/result","seq":36,"time":1785730451406,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"193df31b-de9a-4723-b2b1-c7c96a8eaee4"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1785730451406,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1785730451419,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":39,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":40,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":41,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":42,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":43,"time":1785730451424,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1785730451424,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cbf54e23-fb96-45cc-b629-b9cb48fc9876"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1785730451425,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":46,"time":1785730451437,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"d5d961af-c171-40b6-87a1-33d2c14740a7"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1785730451437,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1785730451450,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":49,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":50,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":51,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":52,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":53,"time":1785730451453,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":54,"time":1785730451453,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fbea6b64-84cf-4411-abec-48d26b3801da"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"step/end","seq":55,"time":1785730451454,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":56,"time":1785730451454,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index e834c02d34..8b8b8cc0c2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,27 +1,29 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784540790312,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784540790312,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"9cf268f7-2c64-4344-8512-dc9cb4dc66d4"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784540790312,"data":{"title":"Call subagent once. Ask that","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534718281,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} -{"type":"user/message","seq":4,"time":1785534718281,"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"},"role":"user","id":"5cb1da9d-4cb0-4ac9-9f3e-f72a09d893da"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534718281,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534718281,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534718281,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"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_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} -{"type":"assistant/chunk","seq":10,"time":1785464656958,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1785487618150,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1785534718290,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785534718290,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c39f921b-e161-4e43-ba17-7cf5d4c41074"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1785534718290,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} -{"type":"tool/result","seq":15,"time":1785534718349,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"e101e96d-c1cc-4321-a4d9-f9f400a8c744"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785534718349,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1785534718357,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":19,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} -{"type":"assistant/chunk","seq":20,"time":1785464657024,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} -{"type":"assistant/chunk","seq":21,"time":1785487618220,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":22,"time":1785534718362,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":23,"time":1785534718362,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"404f7c93-114c-4802-8659-4580946fb70c"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"step/end","seq":24,"time":1785534718362,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":25,"time":1785534718362,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498798860,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} +{"type":"turn/start","seq":1,"time":1785821414174,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821414174,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821414185,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"step/start","seq":4,"time":1785730456013,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730456014,"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":"3244b13c-f211-445f-acf5-fb8d1534537c"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730456014,"data":{"title":"Call subagent once. Ask that","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} +{"type":"assistant/chunk","seq":12,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} +{"type":"tool/result","seq":17,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730456072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1785730456082,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} +{"type":"assistant/chunk","seq":22,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785730456086,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index d35362231d..7f2ae89966 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,27 +1,29 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","origin":"subagent","delegationDepth":2} -{"type":"turn/start","seq":0,"time":1784540790319,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784540790319,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"17f4fd95-842c-4c48-90d9-cdd04456687f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784540790319,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534718311,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} -{"type":"user/message","seq":4,"time":1785534718311,"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"},"role":"user","id":"6b793864-979d-4962-b641-5bb6439afcd8"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534718311,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534718311,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534718311,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"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_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} -{"type":"assistant/chunk","seq":10,"time":1785464656985,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1785487618180,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1785534718320,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785534718320,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cb36f38d-54b2-4730-acd1-063ae39da283"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1785534718320,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":15,"time":1785534718328,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"39af6fe9-28b8-4565-bce4-190e67750f8d"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785534718328,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1785534718336,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":19,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} -{"type":"assistant/chunk","seq":20,"time":1785464657004,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} -{"type":"assistant/chunk","seq":21,"time":1785487618200,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":22,"time":1785534718341,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":23,"time":1785534718341,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ac69b81e-1f3a-4218-b51a-5b4059bddab6"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"step/end","seq":24,"time":1785534718341,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":25,"time":1785534718341,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498798891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} +{"type":"turn/start","seq":1,"time":1785821414201,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821414201,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821414214,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"step/start","seq":4,"time":1785730456041,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730456041,"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":"4a252f7d-8523-433f-a3fc-33812be802ec"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730456041,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} +{"type":"assistant/chunk","seq":12,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} +{"type":"tool/result","seq":17,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730456056,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1785730456066,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} +{"type":"assistant/chunk","seq":22,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} +{"type":"assistant/chunk","seq":23,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785730456071,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index fc0065ed77..6288b6d516 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -1,26 +1,28 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784540790290,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784540790291,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"91026683-5b50-4e7d-abf5-fb5aa68708f6"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784540790291,"data":{"title":"Delegate through two child generations.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464656920,"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"},"role":"user","id":"28e24b47-33af-426b-a614-375f07545ace"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464656920,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464656921,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487618111,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_root_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785464656930,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487618120,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487618120,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"15f73201-9d1e-4582-920c-701bc22618e8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487618120,"data":{"turn":1,"step":1,"callId":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}} -{"type":"tool/result","seq":14,"time":1785487618227,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_root_child"},"content":[{"type":"tool-result","toolCallId":"call_root_child","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"isError":false}],"role":"user","id":"d239f956-1930-4c71-a4ae-49d2669200f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487618227,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487618234,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ROOT_DONE"}}} -{"type":"assistant/chunk","seq":19,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ROOT_DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1785464657042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1785487618238,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785487618238,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"ROOT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"363dcc2a-d452-4e1d-a410-aef97bc9aaf7"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785487618238,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785487618238,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498798808,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"b2260a25-4667-49ed-9297-16b233f22332"}]}} +{"type":"turn/start","seq":1,"time":1785821414127,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821414127,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1784540790308,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498798839,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"b2260a25-4667-49ed-9297-16b233f22332"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730455980,"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":"d3ba1b18-4d27-4c90-a95d-125e9ffc9f29"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730455980,"data":{"title":"Delegate through two child generations.","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498798841,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730455981,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_root_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498798850,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730455990,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730455990,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f0284ec5-b83d-4eca-8f10-d3e4e09d0a39"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730455990,"data":{"turn":1,"step":1,"callId":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}} +{"type":"tool/result","seq":16,"time":1785730456087,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_root_child"},"content":[{"type":"tool-result","toolCallId":"call_root_child","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"isError":false}],"role":"user","id":"6e5d6cdb-d9da-47a0-826a-50f7022b544d"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730456087,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730456097,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ROOT_DONE"}}} +{"type":"assistant/chunk","seq":21,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ROOT_DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":1785498798963,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1785730456101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730456101,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"ROOT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf7259c7-e817-42a4-af8c-d63b755997da"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730456102,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730456102,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 32363fe6cf..a56f7ccf60 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,36 +1,40 @@ -{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":40,"origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"181ef004-8be2-416c-a223-3ff7f99cc79c"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464655093,"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"},"role":"user","id":"33079cbe-843c-4c20-9dbe-4d7052238458"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464655093,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464655093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487615348,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352135654,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,27,0,0,0,1,0,29,1,0,0,26,1,0,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]}} -{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} -{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":35,"time":1785464655104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":36,"time":1785487615358,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":37,"time":1785487615358,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"964d6e93-7b80-419e-bb25-b2c11dfef18e"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","seq":38,"time":1785487615358,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":39,"time":1785487615359,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":40,"time":1785487615386,"data":{}} -{"type":"turn/start","seq":41,"time":1785487615387,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":42,"time":1785487615387,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"162a8354-c69d-410c-8d04-8408724a5ac6"},"surfaceOp":"append"} -{"type":"subagent/descriptor","seq":43,"time":1785534713921,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":44,"time":1785534713921,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":45,"time":1785534713922,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":46,"time":1783352138020,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":47,"time0":1783352138046,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} -{"type":"assistant/chunk","seq":81,"time":1783352138305,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":82,"time0":1783352138307,"data":{"turn":2,"step":1,"index":1,"dt":[0,0,1790166963],"texts":["M","ARM","AL","ADE"]}} -{"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":87,"time":1785464655160,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":88,"time":1785487615414,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":89,"time":1785534713931,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":90,"time":1785534713931,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2bbeb2ce-5f83-44e4-8497-efc539c5f5ff"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":91,"time":1785534713931,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":92,"time":1785534713932,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":42,"origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":1785498796080,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8e65a90a-a69c-44f1-b55f-49fefdabb74c"}]}} +{"type":"turn/start","seq":1,"time":1785821406454,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821406454,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498796115,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8e65a90a-a69c-44f1-b55f-49fefdabb74c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730448968,"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":"40eb2299-67e0-44db-8132-84564259fc8b"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730448968,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498796118,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730448969,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352135654,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,27,0,0,0,1,0,29,1,0,0,26,1,0,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]}} +{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":35,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} +{"type":"assistant/chunk","seq":36,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":37,"time":1785498796128,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":38,"time":1785730448979,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730448979,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7ac2e3d7-d558-4b24-b71e-40fc2f42216d"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730448979,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730448979,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":42,"time":1785730449008,"data":{}} +{"type":"agent/inbox/spliced","seq":43,"time":1785498796160,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} +{"type":"turn/start","seq":44,"time":1785821406523,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":45,"time":1785821406523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":46,"time":1785821406543,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":47,"time":1785730449027,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":48,"time":1785730449027,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} +{"type":"request/header","seq":49,"time":1785730449027,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":50,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":51,"time0":1783352138046,"data":{"turn":2,"step":1,"index":0,"dt":[0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} +{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":86,"time0":1783352138307,"data":{"turn":2,"step":1,"index":1,"dt":[1790166963,239266980,117223942],"texts":["M","ARM","AL","ADE"]}} +{"type":"assistant/chunk","seq":90,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":91,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":94,"time":1785730449034,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"step/end","seq":95,"time":1785730449035,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":96,"time":1785730449035,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index 51e617a484..c7eb17a0cb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,46 +1,50 @@ {"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"a560cd00-2828-4856-bc51-b0e717ca0a5f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464655093,"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"},"role":"user","id":"99b33ab5-8578-4ced-bc7e-ed0f0f9d125c"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464655093,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464655093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487615348,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352135654,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,27,0,0,0,1,0,29,1,0,0,26,1,0,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]}} -{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} -{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":35,"time":1785464655104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":36,"time":1785487615358,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":37,"time":1785487615358,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"03ede8a3-a816-4e2f-9e44-b232665058e7"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","seq":38,"time":1785487615358,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":39,"time":1785487615359,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":40,"time":1785487615359,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":41,"time":1785487615359,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"9492a74d-4846-4f7f-9de4-58d44ed2e99c"},"surfaceOp":"append"} -{"type":"step/start","seq":42,"time":1785487615369,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":43,"time":1783352136226,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":44,"time0":1783352136255,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,26,1,0,0,31,0,27,25,1,27,1,0,28,0,0,0,27,1,27,0,30,27,0,28,0,0,0,0,28,0,1,0,0,28,0,0,0,0,28,29,0,1,0,0,0,27,1,0,26,1,0,0,86,0],"texts":["The"," user"," wants"," me"," to"," use"," sub","agent","_f","ork"," to"," delegate"," a"," question"," to"," a"," child"," agent","."," The"," child"," agent"," inher","its"," this"," conversation"," and"," should"," be"," able"," to"," answer",":"," the"," project"," cod","ew","ord"," is"," MAR","M","AL","ADE","."," After"," the"," sub","agent"," returns",","," I"," should"," reply"," with"," PAR","ENT","_D","ONE","."]}} -{"type":"assistant/chunk","seq":103,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":104,"time0":1783352136847,"data":{"turn":2,"step":1,"index":1,"dt":[0,0,29,1,0,26,0,1,0,0,56,1,0,0,0,0,26,0,0,0,0,28,0,0,0,0,0,28,0,0,0,0,0,28,0,0,0,0,0,28,0,0,59,0],"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","args":["","{","\"","description","\"",": ","\"","Recall"," project"," cod","ew","ord","\"",", ","\"","prom","pt","\"",": ","\"","What"," is"," the"," project"," cod","ew","ord"," mentioned"," earlier"," in"," this"," conversation","?"," Reply"," with"," exactly"," that"," one"," word"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":149,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."}}}} -{"type":"assistant/chunk","seq":150,"time":1783352137159,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":151,"time":1785464655122,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}}}} -{"type":"assistant/chunk","seq":152,"time":1785487615376,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":153,"time":1785487615376,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."},{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1f86a90c-c2ae-4873-a69d-638dbdc5e5c5"},"usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152],"surfaceOp":"append"} -{"type":"tool/call","seq":154,"time":1785487615376,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":155,"time":1785487615426,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sAtKUseRzHRBvL4CF7XF1334"},"content":[{"type":"tool-result","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","content":[{"type":"text","text":"MARMALADE"}],"isError":false}],"role":"user","id":"a4611ce9-c160-4ca7-bba0-da80444a851a"}},"sourceEventSeqs":[154],"surfaceOp":"append"} -{"type":"step/end","seq":156,"time":1785487615427,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":157,"time":1785487615434,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":158,"time":1783352139100,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":159,"time0":1783352139128,"data":{"turn":2,"step":2,"index":0,"dt":[0,0,28,1,0,0,0,29,0,0,0,0,0,29,0,0,1,40,1,0,0,0,16,0],"texts":["The"," for","ked"," child"," agent"," correctly"," returned"," \"","M","ARM","AL","ADE","\"."," Now"," I"," need"," to"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} -{"type":"assistant/chunk","seq":184,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":185,"time0":1783352139273,"data":{"turn":2,"step":2,"index":1,"dt":[0,1,0],"texts":["PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":189,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":190,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":191,"time":1785464655183,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":192,"time":1785487615441,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":193,"time":1785487615441,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"51ad858f-fa31-44e0-9ff5-e4e6c7e7f6aa"},"usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}},"sourceEventSeqs":[158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192],"surfaceOp":"append"} -{"type":"step/end","seq":194,"time":1785487615442,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":195,"time":1785487615442,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498796080,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8e65a90a-a69c-44f1-b55f-49fefdabb74c"}]}} +{"type":"turn/start","seq":1,"time":1785821406454,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821406454,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498796115,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"8e65a90a-a69c-44f1-b55f-49fefdabb74c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730448968,"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":"40eb2299-67e0-44db-8132-84564259fc8b"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730448968,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498796118,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730448969,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352135654,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,27,0,0,0,1,0,29,1,0,0,26,1,0,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]}} +{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":35,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} +{"type":"assistant/chunk","seq":36,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":37,"time":1785498796128,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":38,"time":1785730448979,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730448979,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7ac2e3d7-d558-4b24-b71e-40fc2f42216d"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730448979,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730448979,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":42,"time":1785498796131,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"444d4dbd-e948-45ac-89a9-a56cf91c75e8"}]}} +{"type":"turn/start","seq":43,"time":1785821406495,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":44,"time":1785821406496,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":45,"time":1785730448991,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":46,"time":1785730448991,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"444d4dbd-e948-45ac-89a9-a56cf91c75e8"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":47,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":48,"time0":1783352136256,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,26,1,0,0,31,0,27,25,1,27,1,0,28,0,0,0,27,1,27,0,30,27,0,28,0,0,0,0,28,0,1,0,0,28,0,0,0,0,28,29,0,1,0,0,0,27,1,0,26,1,0,0,86,0,28,0],"texts":["The"," user"," wants"," me"," to"," use"," sub","agent","_f","ork"," to"," delegate"," a"," question"," to"," a"," child"," agent","."," The"," child"," agent"," inher","its"," this"," conversation"," and"," should"," be"," able"," to"," answer",":"," the"," project"," cod","ew","ord"," is"," MAR","M","AL","ADE","."," After"," the"," sub","agent"," returns",","," I"," should"," reply"," with"," PAR","ENT","_D","ONE","."]}} +{"type":"assistant/chunk","seq":107,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":108,"time0":1783352136847,"data":{"turn":2,"step":1,"index":1,"dt":[29,1,0,26,0,1,0,0,56,1,0,0,0,0,26,0,0,0,0,28,0,0,0,0,0,28,0,0,0,0,0,28,0,0,0,0,0,28,0,0,59,0,0,1],"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","args":["","{","\"","description","\"",": ","\"","Recall"," project"," cod","ew","ord","\"",", ","\"","prom","pt","\"",": ","\"","What"," is"," the"," project"," cod","ew","ord"," mentioned"," earlier"," in"," this"," conversation","?"," Reply"," with"," exactly"," that"," one"," word"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":153,"time":1785498796149,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."}}}} +{"type":"assistant/chunk","seq":154,"time":1785498796149,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":155,"time":1785498796149,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}}}} +{"type":"assistant/chunk","seq":156,"time":1785730448998,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":157,"time":1785730448998,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."},{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"37c2b0ec-fab8-4f35-86e9-6f1366a1936e"},"usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156],"surfaceOp":"append"} +{"type":"tool/call","seq":158,"time":1785730448999,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":159,"time":1785730449037,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sAtKUseRzHRBvL4CF7XF1334"},"content":[{"type":"tool-result","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","content":[{"type":"text","text":"MARMALADE"}],"isError":false}],"role":"user","id":"ab76911f-4c1e-43bf-b8c7-ba5173c4f2d6"}},"sourceEventSeqs":[158],"surfaceOp":"append"} +{"type":"step/end","seq":160,"time":1785730449037,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":161,"time":1785730449050,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":162,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":163,"time0":1783352139128,"data":{"turn":2,"step":2,"index":0,"dt":[28,1,0,0,0,29,0,0,0,0,0,29,0,0,1,40,1,0,0,0,16,0,0,0],"texts":["The"," for","ked"," child"," agent"," correctly"," returned"," \"","M","ARM","AL","ADE","\"."," Now"," I"," need"," to"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} +{"type":"assistant/chunk","seq":188,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":189,"time0":1783352139274,"data":{"turn":2,"step":2,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} +{"type":"assistant/chunk","seq":193,"time":1785498796216,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":194,"time":1785498796216,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":195,"time":1785498796216,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":196,"time":1785730449055,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":197,"time":1785730449055,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1dfdd09b-b2f8-4f93-903c-f9548433599f"},"usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}},"sourceEventSeqs":[162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196],"surfaceOp":"append"} +{"type":"step/end","seq":198,"time":1785730449055,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":199,"time":1785730449055,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index 7746bc7f95..d6e54e2096 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,18 +1,20 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785531795641,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785531795641,"data":{}} -{"type":"turn/start","seq":2,"time":1785531795641,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":3,"time":1785531795642,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"33a98a9e-5402-4a7e-b919-05d179da1b84"},"surfaceOp":"append"} -{"type":"session/title","seq":4,"time":1785531795642,"data":{"title":"Reply with exactly the word","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":5,"time":1785531795671,"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"},"role":"user","id":"abfeb4b9-d3d3-47fc-b3fc-534d057ef5c2"},"surfaceOp":"append"} -{"type":"step/start","seq":6,"time":1785531795671,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1785531795672,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785531795672,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"65c53b2a-3ffa-4537-82fd-e6ce33df2c6e"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785531795683,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785531795683,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":2,"time":1785730454803,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} +{"type":"turn/start","seq":3,"time":1785821412774,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":4,"time":1785821412774,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":5,"time":1785730454835,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1785730454835,"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":"524be394-8639-4c12-a41d-799b9e0120a1"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1785730454835,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730454843,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl index f7fc1c169e..1a0113e440 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl @@ -1,47 +1,51 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"437d4bb4-a21e-442e-924e-a968795aec27"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Call the subagent tool once","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785531795622,"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"},"role":"user","id":"84603d0e-6f38-49c1-a622-2eaeee3ebad7"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785531795622,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785531795623,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785531795623,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785531795632,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"12b10b33-c694-4f08-adcd-7ffc229746ca"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785531795632,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":14,"time":1785531795642,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"cac5aed0-bf6b-48a4-89b2-43a382c00b35"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785531795642,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785531795650,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1789000000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"STARTED"}}} -{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} -{"type":"assistant/chunk","seq":20,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785531795656,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"740e57f2-7e00-4611-b3ef-8a9f003df4ab"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785536135021,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785536135021,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":25,"time":1785536135057,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":26,"time":1785536135057,"data":{"content":[{"type":"text","text":"Call list_agents once and observe the subagent you started. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"ec732d1e-a1ab-4fff-bd39-2d2cf6b88851"},"surfaceOp":"append"} -{"type":"step/start","seq":27,"time":1785536135061,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list","name":"list_agents","argumentsDelta":"{}"}}} -{"type":"assistant/chunk","seq":30,"time":1785531795686,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}}}} -{"type":"assistant/chunk","seq":31,"time":1785531795686,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":32,"time":1785536135065,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":1785536135065,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"13ce9c4f-11b1-4cc2-8e8b-68e731911890"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":1785536135065,"data":{"turn":2,"step":1,"callId":"call_list","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","seq":35,"time":1785536135092,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_list"},"content":[{"type":"tool-result","toolCallId":"call_list","content":[{"type":"text","text":"33333333-3333-4333-8333-333333333333 [complete] — Reply with CHILD_OK"}],"isError":false}],"role":"user","id":"a12be6a6-6aab-4bfa-bb79-1bf7d5dfb964"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785536135093,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":37,"time":1785536135101,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":40,"time":1785531795715,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":41,"time":1785531795715,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":42,"time":1785536135106,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":43,"time":1785536135106,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"61f09125-7a29-4420-b2d7-49ecf0b07ea4"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"step/end","seq":44,"time":1785536135106,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":45,"time":1785536135106,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730454756,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"}]}} +{"type":"turn/start","seq":1,"time":1785821412725,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821412725,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785730454783,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730454783,"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":"cc8cb20d-5802-46a9-87b8-d3ee784f8e52"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730454783,"data":{"title":"Call the subagent tool once","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785730454784,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730454784,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":11,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730454793,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730454793,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4dda49d6-5d02-456f-ba95-68699662793d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730454793,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":16,"time":1785730454804,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"7f1fbe79-98b7-410a-af4d-c40d52a366dc"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730454804,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730454814,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"STARTED"}}} +{"type":"assistant/chunk","seq":21,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} +{"type":"assistant/chunk","seq":22,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1785730454820,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730454820,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2ee459fa-21f9-48c6-a42e-3c38eca1e4c9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730454821,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730454821,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":27,"time":1785730454857,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once and observe the subagent you started. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"3bcac2a3-e0db-465b-94b4-4e2761236475"}]}} +{"type":"turn/start","seq":28,"time":1785821412840,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":29,"time":1785821412840,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":30,"time":1785730454863,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":31,"time":1785730454863,"data":{"content":[{"type":"text","text":"Call list_agents once and observe the subagent you started. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"3bcac2a3-e0db-465b-94b4-4e2761236475"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":32,"time":1785531795686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":33,"time":1785536135065,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list","name":"list_agents","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":34,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":36,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":37,"time":1785730454867,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"199c7794-d782-4957-b7ed-69d094b9c0ef"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"tool/call","seq":38,"time":1785730454867,"data":{"turn":2,"step":1,"callId":"call_list","name":"list_agents","arguments":"{}"}} +{"type":"tool/result","seq":39,"time":1785730454893,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_list"},"content":[{"type":"tool-result","toolCallId":"call_list","content":[{"type":"text","text":"33333333-3333-4333-8333-333333333333 [complete] — Reply with CHILD_OK"}],"isError":false}],"role":"user","id":"61ceb650-3e32-413e-9d7e-b6ec8a351858"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730454893,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":41,"time":1785730454903,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":42,"time":1785531795715,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":43,"time":1785536135106,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":44,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":45,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":46,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":47,"time":1785730454907,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"17c1a7c5-84f5-493e-adb5-6a65219e6ad6"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"step/end","seq":48,"time":1785730454908,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":49,"time":1785730454908,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 25460dbe35..e275607cbf 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ad85672f-96fe-4462-a2c7-0411ab9c92c2"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352145224,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534714983,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} -{"type":"user/message","seq":4,"time":1785534714983,"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"},"role":"user","id":"fbb2d7af-c195-4726-9f67-2dcb19d35e36"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534714984,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534714984,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534714984,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":32,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785464656007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":34,"time":1785487616779,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":35,"time":1785534714992,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785534714992,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f8fb22e3-f730-4aad-ab39-ca3d57fedc8d"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"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],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785534714992,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785534714992,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498797416,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} +{"type":"turn/start","seq":1,"time":1785821407754,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821407754,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821407767,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"step/start","seq":4,"time":1785730450187,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730450187,"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":"4a5a7c59-b6f8-47b0-8c09-d9a05607deac"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730450187,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":31,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":34,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":35,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":36,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"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,35,36,37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":1785730450194,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":40,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 369f3d5373..fb6e5e0971 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,36 +1,40 @@ -{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":34,"origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"7d598287-d208-4f2d-a97b-66f397094c6c"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464655947,"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"},"role":"user","id":"cea0bf45-1a24-4b9f-a55c-6c60b66df83c"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464655947,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464655948,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487616723,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352143652,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,25,1,0,0,28,1,0,0,28,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]}} -{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} -{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":29,"time":1785464655958,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":30,"time":1785487616733,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":31,"time":1785487616733,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b135fc45-d8bc-4dee-b76a-fca657c964f4"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} -{"type":"step/end","seq":32,"time":1785487616733,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":33,"time":1785487616733,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":34,"time":1785487616816,"data":{}} -{"type":"turn/start","seq":35,"time":1785487616817,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":36,"time":1785487616817,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"b4019060-df12-4a06-82d8-175c65b22b47"},"surfaceOp":"append"} -{"type":"subagent/descriptor","seq":37,"time":1785534715042,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":38,"time":1785534715042,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":39,"time":1785534715043,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":40,"time":1783352148049,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":41,"time0":1783352148049,"data":{"turn":2,"step":1,"index":0,"dt":[27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} -{"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":73,"time0":1783352148345,"data":{"turn":2,"step":1,"index":1,"dt":[0,1790157964],"texts":["SA","FF","RON"]}} -{"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":77,"time":1785464656072,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":78,"time":1785487616843,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":79,"time":1785534715053,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":80,"time":1785534715053,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"67daf66d-964c-4133-aa33-e709b6806faf"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":81,"time":1785534715053,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":82,"time":1785534715053,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":36,"origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":1785498797352,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"}]}} +{"type":"turn/start","seq":1,"time":1785821407687,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"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":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352143652,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,25,1,0,0,28,1,0,0,28,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]}} +{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":29,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} +{"type":"assistant/chunk","seq":30,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":31,"time":1785498797389,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":32,"time":1785730450146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785730450146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf8355ae-a447-4c41-b01f-beaf74c3e70e"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785730450146,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1785730450146,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":36,"time":1785730450227,"data":{}} +{"type":"agent/inbox/spliced","seq":37,"time":1785498797482,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} +{"type":"turn/start","seq":38,"time":1785821407808,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":39,"time":1785821407808,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":40,"time":1785821407826,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":41,"time":1785730450246,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":42,"time":1785730450246,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} +{"type":"request/header","seq":43,"time":1785730450247,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":44,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":45,"time0":1783352148076,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} +{"type":"assistant/chunk","seq":76,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":77,"time0":1785142306309,"data":{"turn":2,"step":1,"index":1,"dt":[239267243,117223959],"texts":["SA","FF","RON"]}} +{"type":"assistant/chunk","seq":80,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":81,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":84,"time":1785730450254,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":85,"time":1785730450254,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":86,"time":1785730450254,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index de52d69e68..fa390cf176 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,59 +1,63 @@ {"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"f2c31c03-e865-411b-a2be-d9592f5c2583"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464655947,"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"},"role":"user","id":"a8d3db5a-1cca-4055-bda7-bb91fe671181"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464655947,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464655948,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487616723,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352143652,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,25,1,0,0,28,1,0,0,28,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]}} -{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} -{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":29,"time":1785464655958,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":30,"time":1785487616733,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":31,"time":1785487616733,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f2c52abb-08c0-4cef-86a5-da9e4baad065"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} -{"type":"step/end","seq":32,"time":1785487616733,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":33,"time":1785487616733,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":34,"time":1785487616734,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":35,"time":1785487616734,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"e373d8b5-a802-49bd-b8ed-1f18f0d0d38f"},"surfaceOp":"append"} -{"type":"step/start","seq":36,"time":1785487616743,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":37,"time":1783352144477,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":38,"time0":1783352144504,"data":{"turn":2,"step":1,"index":0,"dt":[29,29,1,0,0,28,1,0,0,29,29,0,0,28,1,0,0,0,0,28,1,29,1,0,0,27,29,0,1,0,0,29,68,0],"texts":["Let"," me"," do"," these"," two"," deleg","ations"," one"," at"," a"," time"," as"," requested",".\n\n","First",","," I","'ll"," use"," the"," sub","agent"," tool"," (","fresh"," child",")"," to"," reply"," with"," \"","AL","P","HA","\"."]}} -{"type":"assistant/chunk","seq":73,"time":1783352144931,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":74,"time0":1783352144932,"data":{"turn":2,"step":1,"index":1,"dt":[0,68,1,0,0,0,11,1,0,0,34,0,26,1,0,0,30,0,1,0,0,0,26,0,0,0,0,0,29,1,0,60,0],"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","args":["","{","\"","description","\"",": ","\"","Reply"," AL","P","HA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," AL","P","HA"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":108,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."}}}} -{"type":"assistant/chunk","seq":109,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":110,"time":1785464655975,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}}}} -{"type":"assistant/chunk","seq":111,"time":1785487616749,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":112,"time":1785487616749,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."},{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c3a523a6-3b65-484b-9354-1f4ade38e7d5"},"usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111],"surfaceOp":"append"} -{"type":"tool/call","seq":113,"time":1785487616749,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":114,"time":1785487616789,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_YvHr2bGomk5HhpgDTvE81896"},"content":[{"type":"tool-result","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"ef70eb73-5156-4502-a0c4-66c05f3a7ac9"}},"sourceEventSeqs":[113],"surfaceOp":"append"} -{"type":"step/end","seq":115,"time":1785487616789,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":116,"time":1785487616799,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":117,"time":1783352146837,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":118,"time0":1783352146865,"data":{"turn":2,"step":2,"index":0,"dt":[0,1,0,0,0,31,0,0,0,1,0,25,0,0,0,0,0,28,1,0,0,27,1,0,0,0,29,1,0,0,0,27,0,1,0,0,0,118,0],"texts":["The"," first"," sub","agent"," returned"," \"","AL","P","HA","\"."," Now"," I"," need"," to"," use"," the"," sub","agent","_f","ork"," tool"," (","fork","ed"," child"," that"," inher","its"," this"," conversation",")"," to"," ask"," about"," the"," project"," cod","ew","ord","."]}} -{"type":"assistant/chunk","seq":158,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":159,"time0":1783352147156,"data":{"turn":2,"step":2,"index":1,"dt":[30,0,0,0,28,28,1,0,0,0,60,1,0,0,0,0,26,1,0,0,0,26,0,0,0,0,1,27,0,0,0,1,0,28,0,0,0,0,0,28,0,1,59,0],"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","args":["","{","\"","description","\"",": ","\"","Recall"," project"," cod","ew","ord","\"",", ","\"","prom","pt","\"",": ","\"","What"," is"," the"," project"," cod","ew","ord"," mentioned"," earlier"," in"," this"," conversation","?"," Reply"," with"," exactly"," that"," one"," word"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":204,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."}}}} -{"type":"assistant/chunk","seq":205,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":206,"time":1785464656033,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}}}} -{"type":"assistant/chunk","seq":207,"time":1785487616808,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":208,"time":1785487616808,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."},{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d16a83e9-f643-4071-8096-cab34c299f4e"},"usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}},"sourceEventSeqs":[117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207],"surfaceOp":"append"} -{"type":"tool/call","seq":209,"time":1785487616808,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":210,"time":1785487616852,"data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_JSr5rhREq23wSmwSkCP77184"},"content":[{"type":"tool-result","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","content":[{"type":"text","text":"SAFFRON"}],"isError":false}],"role":"user","id":"dadc193a-4d73-45ac-9362-22ad5e5fcf02"}},"sourceEventSeqs":[209],"surfaceOp":"append"} -{"type":"step/end","seq":211,"time":1785487616852,"data":{"turn":2,"step":2}} -{"type":"step/start","seq":212,"time":1785487616859,"data":{"turn":2,"step":3}} -{"type":"assistant/chunk","seq":213,"time":1783352149189,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":214,"time0":1783352149217,"data":{"turn":2,"step":3,"index":0,"dt":[0,29,0,0,0,0,27,1,31,1,24,1,0,0,0,0,28,0,1,0,0,28,0,0,28,1,0,28,0,29,29,0,33,0,23,29,31,31,0,0,0,27,0,0,0,0,0,29,0,0,1,27,1,0,0,0,27,1,0,28,1,0,0],"texts":["Both"," sub","agents"," returned",":\n","1","."," First"," (","fresh"," child","):"," \"","AL","P","HA","\"\n","2","."," Second"," (","fork","ed"," child","):"," \"","SA","FF","RON","\""," -"," correctly"," inherited"," the"," conversation"," context"," where"," I"," was"," asked"," to"," remember"," the"," cod","ew","ord"," \"","SA","FF","RON","\".\n\n","Now"," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\""," as"," instructed","."]}} -{"type":"assistant/chunk","seq":278,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":279,"time0":1783352149792,"data":{"turn":2,"step":3,"index":1,"dt":[29,0,0],"texts":["PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":283,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}} -{"type":"assistant/chunk","seq":284,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":285,"time":1785464656095,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}}}} -{"type":"assistant/chunk","seq":286,"time":1785487616867,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":287,"time":1785487616867,"data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa97d814-74b1-44d9-8647-80f8edff6701"},"usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}},"sourceEventSeqs":[213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286],"surfaceOp":"append"} -{"type":"step/end","seq":288,"time":1785487616867,"data":{"turn":2,"step":3}} -{"type":"turn/end","seq":289,"time":1785487616867,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498797352,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"}]}} +{"type":"turn/start","seq":1,"time":1785821407687,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"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":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352143652,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,25,1,0,0,28,1,0,0,28,30,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]}} +{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":29,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} +{"type":"assistant/chunk","seq":30,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":31,"time":1785498797389,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":32,"time":1785730450146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785730450146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf8355ae-a447-4c41-b01f-beaf74c3e70e"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785730450146,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1785730450146,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":36,"time":1785498797390,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"80c38716-32d9-4e42-8b93-a094a28ad39e"}]}} +{"type":"turn/start","seq":37,"time":1785821407727,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":38,"time":1785821407727,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":39,"time":1785730450156,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":40,"time":1785730450156,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"80c38716-32d9-4e42-8b93-a094a28ad39e"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":41,"time":1783352144533,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":42,"time0":1783352144562,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,28,1,0,0,29,29,0,0,28,1,0,0,0,0,28,1,29,1,0,0,27,29,0,1,0,0,29,68,0,39,1],"texts":["Let"," me"," do"," these"," two"," deleg","ations"," one"," at"," a"," time"," as"," requested",".\n\n","First",","," I","'ll"," use"," the"," sub","agent"," tool"," (","fresh"," child",")"," to"," reply"," with"," \"","AL","P","HA","\"."]}} +{"type":"assistant/chunk","seq":77,"time":1783352144932,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":78,"time0":1783352145000,"data":{"turn":2,"step":1,"index":1,"dt":[1,0,0,0,11,1,0,0,34,0,26,1,0,0,30,0,1,0,0,0,26,0,0,0,0,0,29,1,0,60,0,0,0],"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","args":["","{","\"","description","\"",": ","\"","Reply"," AL","P","HA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," AL","P","HA"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":112,"time":1785498797406,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."}}}} +{"type":"assistant/chunk","seq":113,"time":1785498797406,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":114,"time":1785498797406,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":115,"time":1785730450163,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":116,"time":1785730450163,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."},{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"834262fa-2ebc-483d-8b8f-96301a20332b"},"usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"tool/call","seq":117,"time":1785730450164,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":118,"time":1785730450197,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_YvHr2bGomk5HhpgDTvE81896"},"content":[{"type":"tool-result","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"18d1dfe7-0cfe-4cd3-8619-d85b2c65445a"}},"sourceEventSeqs":[117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1785730450197,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":120,"time":1785730450212,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":121,"time":1783352146865,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":122,"time0":1783352146866,"data":{"turn":2,"step":2,"index":0,"dt":[0,0,0,31,0,0,0,1,0,25,0,0,0,0,0,28,1,0,0,27,1,0,0,0,29,1,0,0,0,27,0,1,0,0,0,118,0,0,0],"texts":["The"," first"," sub","agent"," returned"," \"","AL","P","HA","\"."," Now"," I"," need"," to"," use"," the"," sub","agent","_f","ork"," tool"," (","fork","ed"," child"," that"," inher","its"," this"," conversation",")"," to"," ask"," about"," the"," project"," cod","ew","ord","."]}} +{"type":"assistant/chunk","seq":162,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":163,"time0":1783352147186,"data":{"turn":2,"step":2,"index":1,"dt":[0,0,28,28,1,0,0,0,60,1,0,0,0,0,26,1,0,0,0,26,0,0,0,0,1,27,0,0,0,1,0,28,0,0,0,0,0,28,0,1,59,0,0,0],"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","args":["","{","\"","description","\"",": ","\"","Recall"," project"," cod","ew","ord","\"",", ","\"","prom","pt","\"",": ","\"","What"," is"," the"," project"," cod","ew","ord"," mentioned"," earlier"," in"," this"," conversation","?"," Reply"," with"," exactly"," that"," one"," word"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":208,"time":1785498797471,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."}}}} +{"type":"assistant/chunk","seq":209,"time":1785498797471,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":210,"time":1785498797471,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}}}} +{"type":"assistant/chunk","seq":211,"time":1785730450219,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":212,"time":1785730450219,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."},{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7790a2a8-64b3-4d98-8d85-6b2667f3adbc"},"usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}},"sourceEventSeqs":[121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211],"surfaceOp":"append"} +{"type":"tool/call","seq":213,"time":1785730450219,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":214,"time":1785730450254,"data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_JSr5rhREq23wSmwSkCP77184"},"content":[{"type":"tool-result","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","content":[{"type":"text","text":"SAFFRON"}],"isError":false}],"role":"user","id":"4e5624d0-b633-4df7-ad19-db764e298422"}},"sourceEventSeqs":[213],"surfaceOp":"append"} +{"type":"step/end","seq":215,"time":1785730450255,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":216,"time":1785730450263,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":217,"time":1783352149217,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":218,"time0":1783352149246,"data":{"turn":2,"step":3,"index":0,"dt":[0,0,0,0,27,1,31,1,24,1,0,0,0,0,28,0,1,0,0,28,0,0,28,1,0,28,0,29,29,0,33,0,23,29,31,31,0,0,0,27,0,0,0,0,0,29,0,0,1,27,1,0,0,0,27,1,0,28,1,0,0,0,0],"texts":["Both"," sub","agents"," returned",":\n","1","."," First"," (","fresh"," child","):"," \"","AL","P","HA","\"\n","2","."," Second"," (","fork","ed"," child","):"," \"","SA","FF","RON","\""," -"," correctly"," inherited"," the"," conversation"," context"," where"," I"," was"," asked"," to"," remember"," the"," cod","ew","ord"," \"","SA","FF","RON","\".\n\n","Now"," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\""," as"," instructed","."]}} +{"type":"assistant/chunk","seq":282,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":283,"time0":1783352149821,"data":{"turn":2,"step":3,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} +{"type":"assistant/chunk","seq":287,"time":1785498797528,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}} +{"type":"assistant/chunk","seq":288,"time":1785498797528,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":289,"time":1785498797528,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}}}} +{"type":"assistant/chunk","seq":290,"time":1785730450269,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":291,"time":1785730450270,"data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"256c985a-449a-4176-9233-7d29cf47ba5e"},"usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}},"sourceEventSeqs":[217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290],"surfaceOp":"append"} +{"type":"step/end","seq":292,"time":1785730450270,"data":{"turn":2,"step":3}} +{"type":"turn/end","seq":293,"time":1785730450270,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index ad531f73f6..7948013736 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"78174c9e-c9f2-4c05-8dc4-12108419e6ad"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352127670,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534712796,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} -{"type":"user/message","seq":4,"time":1785534712796,"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"},"role":"user","id":"e0295965-aacd-4173-a0ad-551a1fd36cb0"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534712796,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534712796,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534712797,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":32,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785464654241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":34,"time":1785487613988,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":35,"time":1785534712805,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785534712805,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6bfa1fd4-4505-425e-ac8c-ab09c76995ea"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"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],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785534712805,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785534712805,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498794788,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} +{"type":"turn/start","seq":1,"time":1785821405232,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821405232,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821405245,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"step/start","seq":4,"time":1785730447828,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730447828,"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":"bab5cdff-7925-478d-b55a-daa2ef524d7c"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730447828,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":31,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":34,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":35,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":36,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"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,35,36,37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":1785730447834,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":40,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 3488db3e92..64e58b3741 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,21 +1,23 @@ {"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9542fa06-afea-4f6d-9db1-b6848def4bc7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352129662,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534712851,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} -{"type":"user/message","seq":4,"time":1785534712851,"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"},"role":"user","id":"bc269a2f-dda1-4536-9eec-a4982f443872"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534712851,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534712851,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534712852,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":27,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":28,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":31,"time":1785464654297,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":32,"time":1785487614041,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":33,"time":1785534712861,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785534712861,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"168875e0-29b7-4a9b-878e-f6090352b0a7"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785534712861,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1785534712861,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498794853,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} +{"type":"turn/start","seq":1,"time":1785821405286,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821405286,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821405299,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"step/start","seq":4,"time":1785730447881,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730447881,"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":"036067ef-a106-4955-841c-a0d2effe51ef"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730447881,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":33,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":34,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"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,35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1785730447887,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":38,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 144b08c654..3a48c2d760 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,45 +1,47 @@ {"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"6ffda6cc-a652-47b7-bcdf-c1a5899cfda5"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352126251,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464654194,"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"},"role":"user","id":"6d0d1d12-3c14-43a9-a25c-a805a9d15ed1"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464654195,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464654195,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487613946,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352126848,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352126877,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,29,0,1,0,0,1,24,30,29,0,0,1,0,30,0,0,29,1,27,0,0,1,0,0,29,29,0,0,0,33,25,1,0,29,0,1,29,0,0,0,0,1,85,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," sub","agent"," tool"," twice",","," sequentially"," (","one"," at"," a"," time",")."," First"," sub","agent"," should"," reply"," with"," \"","AL","P","HA","\","," second"," with"," \"","B","ETA","\"."," After"," both"," return",","," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} -{"type":"assistant/chunk","seq":57,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":58,"time0":1783352127374,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,27,0,1,28,1,0,0,29,26,1,0,0,0,28,1,0,0,29,0,1,0,0,0,31,0,0,1,0,27,60,0],"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","args":["","{","\"","description","\"",": ","\"","Return"," AL","P","HA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," AL","P","HA"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":92,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":93,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":94,"time":1785464654207,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}}}} -{"type":"assistant/chunk","seq":95,"time":1785487613957,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":96,"time":1785487613957,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."},{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c176f9ca-0257-4ad4-af9d-be77aebc3073"},"usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} -{"type":"tool/call","seq":97,"time":1785487613957,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":98,"time":1785487613997,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010"},"content":[{"type":"tool-result","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"281d88a9-6c8f-4353-a29f-e56ebfd8499e"}},"sourceEventSeqs":[97],"surfaceOp":"append"} -{"type":"step/end","seq":99,"time":1785487613997,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":100,"time":1785487614005,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":101,"time":1783352129152,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":102,"time0":1783352129166,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,29,0,0,1,0,0,27,30,0,0,0,0,1,27,1,0,0,0,88,0],"texts":["First"," sub","agent"," returned"," \"","AL","P","HA","\"."," Now"," I","'ll"," call"," the"," second"," sub","agent"," to"," return"," \"","B","ETA","\"."]}} -{"type":"assistant/chunk","seq":125,"time":1783352129399,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":126,"time0":1783352129400,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,28,0,0,0,29,0,28,0,0,0,30,1,0,0,27,0,0,0,0,0,31,0,0,0,0,29,57,1],"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","args":["","{","\"","description","\"",": ","\"","Return"," B","ETA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," B","ETA"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":158,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}}} -{"type":"assistant/chunk","seq":159,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":160,"time":1785464654266,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":161,"time":1785487614012,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":162,"time":1785487614012,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."},{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"286f1d62-fecb-4038-a4c0-3953c9b821d8"},"usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161],"surfaceOp":"append"} -{"type":"tool/call","seq":163,"time":1785487614012,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} -{"type":"tool/result","seq":164,"time":1785487614052,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_FudNKuJ0fchSptGy3Scw1411"},"content":[{"type":"tool-result","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","content":[{"type":"text","text":"BETA"}],"isError":false}],"role":"user","id":"869a52f9-8155-4e2c-b5cb-dde6b65d7794"}},"sourceEventSeqs":[163],"surfaceOp":"append"} -{"type":"step/end","seq":165,"time":1785487614052,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":166,"time":1785487614061,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":167,"time":1783352131045,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":168,"time0":1783352131073,"data":{"turn":1,"step":3,"index":0,"dt":[0,0,0,23,1,31,0,1,0,0,0,28,1,0,0,0,0,27,0,1,0,0,27,0,0,1,0,27,1],"texts":["Both"," sub","agents"," have"," returned",":"," first"," with"," \"","AL","P","HA","\","," second"," with"," \"","B","ETA","\"."," Now"," I"," should"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} -{"type":"assistant/chunk","seq":198,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":199,"time0":1783352131242,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":203,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":204,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":205,"time":1785464654324,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":206,"time":1785487614068,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":207,"time":1785487614068,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e623a19e-6b6c-4278-859c-7fe83083ab9d"},"usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"} -{"type":"step/end","seq":208,"time":1785487614068,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":209,"time":1785487614068,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498794739,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"07bf16df-0499-420d-9510-3204061f0122"}]}} +{"type":"turn/start","seq":1,"time":1785821405184,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821405184,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352126252,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498794765,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"07bf16df-0499-420d-9510-3204061f0122"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730447790,"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":"9b4b262d-cbd7-4cd8-b24b-70b2b401b0fe"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730447790,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498794766,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730447791,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352126848,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352126877,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,29,0,1,0,0,1,24,30,29,0,0,1,0,30,0,0,29,1,27,0,0,1,0,0,29,29,0,0,0,33,25,1,0,29,0,1,29,0,0,0,0,1,85,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," sub","agent"," tool"," twice",","," sequentially"," (","one"," at"," a"," time",")."," First"," sub","agent"," should"," reply"," with"," \"","AL","P","HA","\","," second"," with"," \"","B","ETA","\"."," After"," both"," return",","," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} +{"type":"assistant/chunk","seq":59,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":60,"time0":1783352127374,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,27,0,1,28,1,0,0,29,26,1,0,0,0,28,1,0,0,29,0,1,0,0,0,31,0,0,1,0,27,60,0],"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","args":["","{","\"","description","\"",": ","\"","Return"," AL","P","HA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," AL","P","HA"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":94,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":95,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":96,"time":1785498794778,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}}}} +{"type":"assistant/chunk","seq":97,"time":1785730447803,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":98,"time":1785730447803,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."},{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c5e5bb9c-4d33-44c5-b5c3-ec3afa42d58b"},"usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}},"sourceEventSeqs":[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,97],"surfaceOp":"append"} +{"type":"tool/call","seq":99,"time":1785730447803,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":100,"time":1785730447837,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010"},"content":[{"type":"tool-result","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"735a5fe2-0978-4fda-b8ad-bc22114b7016"}},"sourceEventSeqs":[99],"surfaceOp":"append"} +{"type":"step/end","seq":101,"time":1785730447837,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":102,"time":1785730447852,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":103,"time":1783352129152,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":104,"time0":1783352129166,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,29,0,0,1,0,0,27,30,0,0,0,0,1,27,1,0,0,0,88,0],"texts":["First"," sub","agent"," returned"," \"","AL","P","HA","\"."," Now"," I","'ll"," call"," the"," second"," sub","agent"," to"," return"," \"","B","ETA","\"."]}} +{"type":"assistant/chunk","seq":127,"time":1783352129399,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":128,"time0":1783352129400,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,28,0,0,0,29,0,28,0,0,0,30,1,0,0,27,0,0,0,0,0,31,0,0,0,0,29,57,1],"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","args":["","{","\"","description","\"",": ","\"","Return"," B","ETA"," only","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," B","ETA"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":160,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}}} +{"type":"assistant/chunk","seq":161,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":162,"time":1785498794844,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":163,"time":1785730447858,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":164,"time":1785730447858,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."},{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6a713c38-b05b-4bb3-9103-bf11a2bc383f"},"usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}},"sourceEventSeqs":[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,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163],"surfaceOp":"append"} +{"type":"tool/call","seq":165,"time":1785730447859,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} +{"type":"tool/result","seq":166,"time":1785730447888,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_FudNKuJ0fchSptGy3Scw1411"},"content":[{"type":"tool-result","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","content":[{"type":"text","text":"BETA"}],"isError":false}],"role":"user","id":"d882a00e-e991-4e2a-9610-698f6bc8924d"}},"sourceEventSeqs":[165],"surfaceOp":"append"} +{"type":"step/end","seq":167,"time":1785730447888,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":168,"time":1785730447902,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":169,"time":1783352131045,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":170,"time0":1783352131073,"data":{"turn":1,"step":3,"index":0,"dt":[0,0,0,23,1,31,0,1,0,0,0,28,1,0,0,0,0,27,0,1,0,0,27,0,0,1,0,27,1],"texts":["Both"," sub","agents"," have"," returned",":"," first"," with"," \"","AL","P","HA","\","," second"," with"," \"","B","ETA","\"."," Now"," I"," should"," reply"," with"," \"","PAR","ENT","_D","ONE","\"."]}} +{"type":"assistant/chunk","seq":200,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":201,"time0":1783352131242,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} +{"type":"assistant/chunk","seq":205,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":206,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":207,"time":1785498794910,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":208,"time":1785730447907,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":209,"time":1785730447907,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b51ff9b8-1c06-485e-8e42-5eac7675c590"},"usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208],"surfaceOp":"append"} +{"type":"step/end","seq":210,"time":1785730447907,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":211,"time":1785730447907,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl index b33bcddf33..4578a14b09 100644 --- a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl @@ -1,26 +1,28 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"8e9c68d6-2472-4db2-af41-2a3fb9f319ac"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate one foreground subagent. Its","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785537317609,"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"},"role":"user","id":"f36ed168-0e1b-4043-83ab-dc6416e50133"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785537317609,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785537317610,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785537317610,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_published_failure","name":"subagent","argumentsDelta":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785537317619,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785537317619,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785537317619,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b2a2fabd-99fa-46e2-bd27-15f6588d3a83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785537317619,"data":{"turn":1,"step":1,"callId":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}} -{"type":"tool/result","seq":14,"time":1785537317629,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_published_failure"},"content":[{"type":"tool-result","toolCallId":"call_published_failure","content":[{"type":"text","text":"Error: subagent run failed: Error: snapshot published run failed; dispose failed: Error: snapshot published handle disposal failed"}],"isError":true}],"role":"user","id":"4fa4173b-33b1-4c3e-a9fd-1e1a0670aeca"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785537317629,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785537317637,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ERROR"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_ERROR"}}}} -{"type":"assistant/chunk","seq":20,"time":1785537317641,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1785537317641,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785537317642,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ERROR"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c686beb-7780-404a-818e-e51f4f233f16"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785537317642,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785537317642,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730452478,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"07e6bcfc-3d70-46ef-8bdd-17a45c2c346e"}]}} +{"type":"turn/start","seq":1,"time":1785821410243,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821410244,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785730452505,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785730452505,"data":{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"07e6bcfc-3d70-46ef-8bdd-17a45c2c346e"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730452505,"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":"902b2d5b-6b6a-471a-b765-5a5ca5d0ff53"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730452505,"data":{"title":"Delegate one foreground subagent. Its","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785730452506,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730452506,"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":"call_published_failure","name":"subagent","argumentsDelta":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1785537317619,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785537317619,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730452515,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730452515,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6af49587-fae3-4027-922d-bdc7b833e6d2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730452515,"data":{"turn":1,"step":1,"callId":"call_published_failure","name":"subagent","arguments":"{\"description\":\"Fail published run\",\"prompt\":\"This child prompt must never run.\"}"}} +{"type":"tool/result","seq":16,"time":1785730452525,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_published_failure"},"content":[{"type":"tool-result","toolCallId":"call_published_failure","content":[{"type":"text","text":"Error: subagent run failed: Error: snapshot published run failed; dispose failed: Error: snapshot published handle disposal failed"}],"isError":true}],"role":"user","id":"9a913910-4a99-4a22-825f-2cf9d51e665a"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730452525,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730452536,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ERROR"}}} +{"type":"assistant/chunk","seq":21,"time":1785537317641,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_ERROR"}}}} +{"type":"assistant/chunk","seq":22,"time":1785537317641,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1785730452539,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730452540,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ERROR"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04a4fe93-dd92-4f86-9376-9b3da097b2ce"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730452540,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730452540,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index ae3ae7dabc..b9992f1519 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,28 +1,30 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785594881508,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785594881508,"data":{}} -{"type":"turn/start","seq":2,"time":1785594881509,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":3,"time":1785594881509,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"ce725d72-5a76-4b07-bd67-031c4e566d44"},"surfaceOp":"append"} -{"type":"session/title","seq":4,"time":1785594881509,"data":{"title":"Call the report tool once","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":5,"time":1785594881538,"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"},"role":"user","id":"43862c40-e00f-47ef-acdf-6faf3d31622f"},"surfaceOp":"append"} -{"type":"step/start","seq":6,"time":1785594881538,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1785594881538,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785594881539,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":10,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} -{"type":"assistant/chunk","seq":11,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} -{"type":"assistant/chunk","seq":12,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":13,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785594881546,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a26b283e-76d6-40ef-9403-07ce44ffbef5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":1785594881547,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} -{"type":"tool/result","seq":16,"time":1785594881554,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 8513f46f-a7e6-4292-9f39-8843b4748b3c"}],"isError":false}],"role":"user","id":"7c541c7c-3c5d-4679-ae1b-235b860eec4d"}},"sourceEventSeqs":[15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785594881554,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":1785594881563,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} -{"type":"assistant/chunk","seq":21,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} -{"type":"assistant/chunk","seq":22,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785594881567,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d9e3ee5c-c9e8-4e4b-90aa-b3d6359919ae"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785594881567,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":26,"time":1785594881567,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":2,"time":1785730453612,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} +{"type":"turn/start","seq":3,"time":1785821411475,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":4,"time":1785821411475,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":5,"time":1785730453639,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1785730453639,"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":"67c76a21-6142-45a6-9a49-0485f51edc8d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1785730453639,"data":{"title":"Call the report tool once","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} +{"type":"tool/result","seq":18,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}],"isError":false}],"role":"user","id":"e6764773-c667-40b5-a13f-8bdc5a9c7762"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730453654,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730453664,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} +{"type":"assistant/chunk","seq":23,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} +{"type":"assistant/chunk","seq":24,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":25,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730453668,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index f5adbf5d95..db5cd4a77f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -1,38 +1,44 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"dd8fabed-440b-4993-a5b3-c8dc8276b5bc"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000002,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785501592842,"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"},"role":"user","id":"883e0516-bfc1-4036-bcb1-65e4bfec065d"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785501592842,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785501592842,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785501592843,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785501592851,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9b15a4a9-6fc6-4ae3-b7c3-324438a31e60"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785501592852,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":14,"time":1785501592863,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"9c5befab-6eb7-450e-b70a-e9b6f50e59fd"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785501592864,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785501592872,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"STARTED"}}} -{"type":"assistant/chunk","seq":19,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} -{"type":"assistant/chunk","seq":20,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1785501592877,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"603fbfa3-1810-495e-a640-7073392b496b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785501592877,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1785501592877,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"user/message","seq":25,"time":1785469571237,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"418727a1-5001-4735-ae60-2ca33256ac3f"},"surfaceOp":"append"} -{"type":"turn/start","seq":26,"time":1785501592940,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":27,"time":1785501592940,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"c079dbd9-893d-42b3-a260-417e8de8adca"},"surfaceOp":"append"} -{"type":"step/start","seq":28,"time":1785501592944,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":29,"time":1789000000029,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":30,"time":1789000000030,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} -{"type":"assistant/chunk","seq":31,"time":1785469571246,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} -{"type":"assistant/chunk","seq":32,"time":1785501592948,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":33,"time":1785501592948,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785501592948,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a73a907-f1fb-49a5-9273-536b6dbf1628"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785501592948,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":36,"time":1785501592948,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730453561,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"}]}} +{"type":"turn/start","seq":1,"time":1785821411429,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"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":"77141070-eb99-4ec0-908d-646c387982f6"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":11,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730453601,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730453602,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"664c39ff-9dac-4bb3-a151-e18a7863d15a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730453602,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":16,"time":1785730453613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"d7927ad2-29db-4de8-ab4d-59a4ebcddd72"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730453613,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730453623,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"STARTED"}}} +{"type":"assistant/chunk","seq":21,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} +{"type":"assistant/chunk","seq":22,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1785730453628,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785730453628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"571faad7-adbd-480c-922a-1499e1329ead"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785730453628,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1785730453629,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":27,"time":1785730453654,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}]}} +{"type":"agent/inbox/spliced","seq":28,"time":1785730453673,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"43f17984-22c3-48b9-911e-923a2f68dce0"}]}} +{"type":"turn/start","seq":29,"time":1785821411548,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":30,"time":1785730453673,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":31,"time":1785821411548,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":32,"time":1785730453683,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":33,"time":1785730453683,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"},"surfaceOp":"append"} +{"type":"user/message","seq":34,"time":1785730453683,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"43f17984-22c3-48b9-911e-923a2f68dce0"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":35,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} +{"type":"assistant/chunk","seq":37,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} +{"type":"assistant/chunk","seq":38,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":40,"time":1785730453687,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"77bba235-d2b6-4a32-9ba3-ebb69d9b0654"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"step/end","seq":41,"time":1785730453687,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":42,"time":1785730453687,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 52d9bc96b3..7fa229c2e3 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c7b8251e-05a6-48da-b7e1-00612d1679a4"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352120856,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534711744,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} -{"type":"user/message","seq":4,"time":1785534711745,"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"},"role":"user","id":"c5e8f2e3-b145-4d6e-97f9-4d85374410cb"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534711745,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534711745,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534711745,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":26,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":27,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":30,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} -{"type":"assistant/chunk","seq":31,"time":1785464653313,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":32,"time":1785487612679,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":33,"time":1785534711753,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785534711753,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cbea1608-409b-4c7d-bf3b-830c9699d4cc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"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],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785534711753,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1785534711753,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498793648,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} +{"type":"turn/start","seq":1,"time":1785821404007,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821404007,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821404020,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"step/start","seq":4,"time":1785730446720,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730446720,"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":"1b537017-6493-4f52-8504-01a7384e8cc6"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730446720,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":29,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":32,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} +{"type":"assistant/chunk","seq":33,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":34,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"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,35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1785730446727,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":38,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index e901b298fe..edf8950dac 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,32 +1,34 @@ {"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"81660fb9-9757-457f-863e-2a0c229ad47a"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352119274,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464653263,"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"},"role":"user","id":"78808c88-e491-4238-9e4e-eb903af2e436"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464653263,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464653263,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487612635,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352120053,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352120080,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,30,1,0,0,1,23,1,0,0,0,0,27,0,28,0,29,0,0,0,0,1,26,0,1,0,0,0,28,0,0,1,0,27,0,0,1,0,0,28,0,0,0,0,0,27,1,0,32,1,0,1,0,1,0,24,0,28,1,0,0,0,26,56,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," to"," delegate"," the"," task",":"," \"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".\"\n","2","."," After"," the"," sub","agent"," returns",","," reply"," with"," the"," single"," word"," PAR","ENT","_D","ONE"," and"," stop",".\n","3","."," Do"," not"," use"," the"," bash"," tool",".\n\n","Let"," me"," do"," this","."]}} -{"type":"assistant/chunk","seq":75,"time":1783352120559,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":76,"time0":1783352120560,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,0,0,29,1,0,0,25,28,0,0,1,0,28,2,0,1,25,1,0,0,0,0,36,0,1,0,0,18,67,0],"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","args":["","{","\"","description","\"",": ","\"","Reply"," with"," CH","ILD","_OK","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":110,"time":1783352120852,"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 to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}}} -{"type":"assistant/chunk","seq":111,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":112,"time":1785464653278,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}}} -{"type":"assistant/chunk","seq":113,"time":1785487612647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":114,"time":1785487612647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."},{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"23ad7888-f296-4294-9b58-2bf0cfe38264"},"usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} -{"type":"tool/call","seq":115,"time":1785487612647,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":116,"time":1785487612689,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_gVbLWC12Qu8JheZpVRRz8749"},"content":[{"type":"tool-result","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","content":[{"type":"text","text":"CHILD_OK"}],"isError":false}],"role":"user","id":"a5202d7b-a761-480e-a83e-96fb660c1af5"}},"sourceEventSeqs":[115],"surfaceOp":"append"} -{"type":"step/end","seq":117,"time":1785487612689,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":118,"time":1785487612698,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":119,"time":1783352122524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":120,"time0":1783352122552,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,28,1,0,28,0,0,1,0,0,31,0,0,32,0,0,0,1,0,26,0,1,0,0,0],"texts":["The"," sub","agent"," returned"," \"","CH","ILD","_OK","\""," as"," expected","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," \"","PAR","ENT","_D","ONE","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":150,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":151,"time0":1783352122731,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":155,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":156,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":157,"time":1785464653338,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":158,"time":1785487612705,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":159,"time":1785487612705,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ad99e30-a809-42e1-b438-cf8500552989"},"usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}},"sourceEventSeqs":[119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} -{"type":"step/end","seq":160,"time":1785487612705,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":161,"time":1785487612705,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498793599,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"a9485ebd-2b4a-434a-bc35-afd757ce141b"}]}} +{"type":"turn/start","seq":1,"time":1785821403947,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821403947,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352119275,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498793625,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"a9485ebd-2b4a-434a-bc35-afd757ce141b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730446685,"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":"e40b1354-1856-48c3-a638-1be67af32920"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730446685,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498793626,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730446686,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352120053,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352120080,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,30,1,0,0,1,23,1,0,0,0,0,27,0,28,0,29,0,0,0,0,1,26,0,1,0,0,0,28,0,0,1,0,27,0,0,1,0,0,28,0,0,0,0,0,27,1,0,32,1,0,1,0,1,0,24,0,28,1,0,0,0,26,56,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," to"," delegate"," the"," task",":"," \"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".\"\n","2","."," After"," the"," sub","agent"," returns",","," reply"," with"," the"," single"," word"," PAR","ENT","_D","ONE"," and"," stop",".\n","3","."," Do"," not"," use"," the"," bash"," tool",".\n\n","Let"," me"," do"," this","."]}} +{"type":"assistant/chunk","seq":77,"time":1783352120559,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":78,"time0":1783352120560,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,0,0,29,1,0,0,25,28,0,0,1,0,28,2,0,1,25,1,0,0,0,0,36,0,1,0,0,18,67,0],"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","args":["","{","\"","description","\"",": ","\"","Reply"," with"," CH","ILD","_OK","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".","\"","}"]}} +{"type":"assistant/chunk","seq":112,"time":1783352120852,"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 to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}}} +{"type":"assistant/chunk","seq":113,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":114,"time":1785498793638,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}}} +{"type":"assistant/chunk","seq":115,"time":1785730446696,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":116,"time":1785730446697,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."},{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4da5cf2f-f9bd-4f1b-9c60-c9a56a7dae75"},"usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"tool/call","seq":117,"time":1785730446697,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":118,"time":1785730446730,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_gVbLWC12Qu8JheZpVRRz8749"},"content":[{"type":"tool-result","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","content":[{"type":"text","text":"CHILD_OK"}],"isError":false}],"role":"user","id":"8afba8c6-ad6a-4389-851b-77fc8f1fe5c9"}},"sourceEventSeqs":[117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1785730446730,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":120,"time":1785730446739,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":121,"time":1783352122524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":122,"time0":1783352122552,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,0,0,28,1,0,28,0,0,1,0,0,31,0,0,32,0,0,0,1,0,26,0,1,0,0,0],"texts":["The"," sub","agent"," returned"," \"","CH","ILD","_OK","\""," as"," expected","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," \"","PAR","ENT","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":152,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":153,"time0":1783352122731,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}} +{"type":"assistant/chunk","seq":157,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":158,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":159,"time":1785498793689,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":160,"time":1785730446743,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":161,"time":1785730446744,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"82643563-e845-4bfa-9e47-98b353d54a39"},"usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}},"sourceEventSeqs":[121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1785730446744,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":163,"time":1785730446744,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 4ab87bf0d3..5016b38b30 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"fb3dd685-1480-42ce-84fe-43f7e9adae7f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464630030,"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"},"role":"user","id":"daa71a05-86ce-47da-abd6-11b389c5b2d2"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464630030,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464630031,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487575645,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} -{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} -{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464630041,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":34,"time":1785487575654,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487575654,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7a7f436f-2b64-46ca-8255-829ffb0b0f5e"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487575654,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785487575654,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3b028c0c-080e-4de0-8339-9aef7fa4769f"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[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":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index 4d4e02e0a0..46461efaa1 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,34 +1,36 @@ {"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"e8fdc2a4-3bd6-4cf1-8cd1-a8ab2461e6de"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352057655,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464637610,"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"},"role":"user","id":"9856bb75-093c-4ce4-b502-fca72ac56512"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464637610,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464637610,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487587724,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352058466,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0,91,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":39,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":40,"time0":1783352058747,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,28,0,1,29,1,0,0,0,0,26,0,0,0,0,0,30,1,0,0,0,0,26,1,0,0,0,0,28,0,0,0,0,0,29,0,0,0,0,1,28,0,0,0,1,0,27,1,0,28,62,1],"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","args":["","{","\"","t","odos","\"",": ","[","{\"","content","\":"," \"","read"," the"," code","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","write"," the"," fix","\","," \"","status","\":"," \"","pending","\"},"," {\"","content","\":"," \"","run"," the"," tests","\","," \"","status","\":"," \"","pending","\"","}]","}"]}} -{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":95,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} -{"type":"assistant/chunk","seq":96,"time":1785464637623,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":97,"time":1785487587735,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":98,"time":1785487587735,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b357a300-fa12-41d1-aef7-7028985451b2"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"} -{"type":"tool/call","seq":99,"time":1785487587735,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} -{"type":"todo/write","seq":100,"time":1785487587744,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} -{"type":"tool/result","seq":101,"time":1785487587745,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fjAnBThbDjxepBtp3hDt3264"},"content":[{"type":"tool-result","toolCallId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"119f66f2-0f7b-4901-8c6a-c4a122b82398"}},"sourceEventSeqs":[99],"surfaceOp":"append"} -{"type":"step/end","seq":102,"time":1785487587745,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":103,"time":1785487587753,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":104,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":105,"time0":1783352059863,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,28,0,1,0,27,0,1,0,0,29,0,0,0,1,0,28,0],"texts":["The"," todos"," have"," been"," written"," successfully","."," Now"," I"," just"," need"," to"," reply"," with"," the"," single"," word"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":126,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":130,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":131,"time":1785464637648,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":132,"time":1785487587758,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":133,"time":1785487587758,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2bfdcd86-d54c-44e9-b909-6ac20e1b8500"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":134,"time":1785487587758,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":135,"time":1785487587758,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498772484,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"5ecf5e4b-6a18-447d-9341-48f38afdd12e"}]}} +{"type":"turn/start","seq":1,"time":1785821376741,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821376741,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352057657,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498772510,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"5ecf5e4b-6a18-447d-9341-48f38afdd12e"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730425725,"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":"8d4ac045-8016-4cec-8b12-91d9459231e1"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730425725,"data":{"title":"Use the todo_write tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498772511,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730425726,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352058466,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0,91,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":41,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":42,"time0":1783352058747,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,28,0,1,29,1,0,0,0,0,26,0,0,0,0,0,30,1,0,0,0,0,26,1,0,0,0,0,28,0,0,0,0,0,29,0,0,0,0,1,28,0,0,0,1,0,27,1,0,28,62,1],"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","args":["","{","\"","t","odos","\"",": ","[","{\"","content","\":"," \"","read"," the"," code","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","write"," the"," fix","\","," \"","status","\":"," \"","pending","\"},"," {\"","content","\":"," \"","run"," the"," tests","\","," \"","status","\":"," \"","pending","\"","}]","}"]}} +{"type":"assistant/chunk","seq":96,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":97,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} +{"type":"assistant/chunk","seq":98,"time":1785498772522,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":99,"time":1785730425738,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":100,"time":1785730425738,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"08141496-5477-4d05-b2c7-414865ea9a17"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[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,97,98,99],"surfaceOp":"append"} +{"type":"tool/call","seq":101,"time":1785730425739,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} +{"type":"todo/write","seq":102,"time":1785730425747,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} +{"type":"tool/result","seq":103,"time":1785730425748,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fjAnBThbDjxepBtp3hDt3264"},"content":[{"type":"tool-result","toolCallId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"c178ad5b-7c1f-4239-9aa6-20d1c6b00a82"}},"sourceEventSeqs":[101],"surfaceOp":"append"} +{"type":"step/end","seq":104,"time":1785730425748,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":105,"time":1785730425759,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":106,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":107,"time0":1783352059863,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,28,0,1,0,27,0,1,0,0,29,0,0,0,1,0,28,0],"texts":["The"," todos"," have"," been"," written"," successfully","."," Now"," I"," just"," need"," to"," reply"," with"," the"," single"," word"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":130,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":131,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":132,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":133,"time":1785498772545,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":134,"time":1785730425764,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":135,"time":1785730425764,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c4e454ce-14cc-4030-be47-0395ac9f12fb"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":136,"time":1785730425764,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":137,"time":1785730425764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 2b783fc4f5..c5f2f28f4d 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352044771,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352044771,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"69e37ec4-14a8-45bb-a477-0b9a55408110"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352044771,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464631648,"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"},"role":"user","id":"4d37cd59-1393-4a96-ad58-6f281c8e04bb"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464631648,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464631649,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487578348,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352045396,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352045425,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,1,29,0,0,1,0,24,1,0,0,89,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," then"," reply"," with"," D","ONE","."]}} -{"type":"assistant/chunk","seq":25,"time":1783352045600,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":26,"time0":1783352045600,"data":{"turn":1,"step":1,"index":1,"dt":[0,1,0,28,1,0,0,0,29,1,0,0,28,1,27,1,0,0,27,0,29,0,0,0,0,0,29,0,64,0],"id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," S","NA","PS","H","OT","_OK","\"",", ","\"","description","\"",": ","\"","Run"," echo"," S","NA","PS","H","OT","_OK","\"","}"]}} -{"type":"assistant/chunk","seq":57,"time":1783352045866,"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 then reply with DONE."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352045866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1785464631660,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2879,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":60,"time":1785487578358,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1785487578358,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with DONE."},{"type":"tool-call","id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"166dd741-af73-4211-b372-98f93ad0f5e8"},"usage":{"inputTokens":2879,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1785487578358,"data":{"turn":1,"step":1,"callId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} -{"type":"tool/result","seq":63,"time":1785487578376,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077"},"content":[{"type":"tool-result","toolCallId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}],"role":"user","id":"adaee9ec-bb31-4f35-aa07-e8c8ba4b0373"}},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":64,"time":1785487578377,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":65,"time":1785487578384,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":66,"time":1783352046981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":67,"time0":1783352047010,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,28,28,0,1,0,0,28,0,0,1,0,0,28,1,0,0,0,29,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," S","NA","PS","H","OT","_OK","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE","."]}} -{"type":"assistant/chunk","seq":92,"time":1783352047155,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":93,"time":1783352047155,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":94,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":95,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."}}}} -{"type":"assistant/chunk","seq":96,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":97,"time":1785464631693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":98,"time":1785487578391,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":99,"time":1785487578391,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b1d6b3e4-e307-4861-8193-4e41443e5239"},"usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}},"sourceEventSeqs":[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,97,98],"surfaceOp":"append"} -{"type":"step/end","seq":100,"time":1785487578391,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":101,"time":1785487578391,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498764160,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"fe479aa0-1194-40fb-897b-bc7f99b54148"}]}} +{"type":"turn/start","seq":1,"time":1785821362944,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821362944,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352044773,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498764188,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"fe479aa0-1194-40fb-897b-bc7f99b54148"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730417556,"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":"11ca1551-2073-4990-bf8c-828c614d47a8"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730417556,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498764190,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730417557,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352045396,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352045425,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,1,29,0,0,1,0,24,1,0,0,89,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," then"," reply"," with"," D","ONE","."]}} +{"type":"assistant/chunk","seq":27,"time":1783352045600,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":28,"time0":1783352045600,"data":{"turn":1,"step":1,"index":1,"dt":[0,1,0,28,1,0,0,0,29,1,0,0,28,1,27,1,0,0,27,0,29,0,0,0,0,0,29,0,64,0],"id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," S","NA","PS","H","OT","_OK","\"",", ","\"","description","\"",": ","\"","Run"," echo"," S","NA","PS","H","OT","_OK","\"","}"]}} +{"type":"assistant/chunk","seq":59,"time":1783352045866,"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 then reply with DONE."}}}} +{"type":"assistant/chunk","seq":60,"time":1783352045866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1785498764200,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2879,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":62,"time":1785730417567,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1785730417567,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with DONE."},{"type":"tool-call","id":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1d5e73ab-6aea-4555-ae64-00e2772e3b82"},"usage":{"inputTokens":2879,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1785730417568,"data":{"turn":1,"step":1,"callId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} +{"type":"tool/result","seq":65,"time":1785730417585,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077"},"content":[{"type":"tool-result","toolCallId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}],"role":"user","id":"ce8a3629-ce77-49bb-b426-eeeefb120c90"}},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"step/end","seq":66,"time":1785730417585,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":67,"time":1785730417595,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":68,"time":1783352046981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":69,"time0":1783352047010,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,28,28,0,1,0,0,28,0,0,1,0,0,28,1,0,0,0,29,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," S","NA","PS","H","OT","_OK","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE","."]}} +{"type":"assistant/chunk","seq":94,"time":1783352047155,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":95,"time":1783352047155,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."}}}} +{"type":"assistant/chunk","seq":98,"time":1783352047156,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":99,"time":1785498764233,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":100,"time":1785730417600,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":101,"time":1785730417600,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ad76b9dd-271f-4b2b-bcda-80bb9e169513"},"usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}},"sourceEventSeqs":[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,97,98,99,100],"surfaceOp":"append"} +{"type":"step/end","seq":102,"time":1785730417601,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":103,"time":1785730417601,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index 020f65ca60..87c02446d6 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -1,33 +1,35 @@ {"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"af4d34ee-c2ca-449b-8df5-7dcd41b97c3d"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464640204,"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"},"role":"user","id":"4a5b50ae-435b-4040-b759-db8dedb60de3"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464640205,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464640205,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487591781,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":7,"time":1785078728943,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1785078728989,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,48,0,1,0,46,1,0,0,0,0,46,1,0,0,0,0,49,0,1,0,0,0,47,0,0,0,0,1,45,1,0,0,45,1,0,0,140,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," web","_f","etch"," tool"," exactly"," once"," to"," fetch"," http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":52,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":53,"time0":1785078729511,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,46,0,0,1,46,0,0,0,0,1,46,1,0,0,0,0,45,1,105,0],"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","args":["","{","\"","url","\"",": ","\"","http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html","\"","}"]}} -{"type":"assistant/chunk","seq":77,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} -{"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} -{"type":"assistant/chunk","seq":79,"time":1785464640217,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} -{"type":"assistant/chunk","seq":80,"time":1785487591791,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":81,"time":1785487591791,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"5d55e60b-ee83-49dc-b062-26201b37b653"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"} -{"type":"tool/call","seq":82,"time":1785487591792,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} -{"type":"tool/result","seq":83,"time":1785487591820,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"7106d1fb-aef6-43ef-a402-454a7635d6c0"},"meta":{"url":"http://127.0.0.1:43117/menu.html","statusCode":200,"truncated":false}},"sourceEventSeqs":[82],"surfaceOp":"append"} -{"type":"step/end","seq":84,"time":1785487591821,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":85,"time":1785487591830,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":86,"time":1785078730770,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":87,"time0":1785078730824,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,36,1,47,47,46,1,0,0,47,0,0,0,0,1,46,43,1,0,0,48,0,46,0,0,0,0,1],"texts":["The"," user"," asked"," me"," to"," fetch"," the"," URL",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," I","'ve"," fetched"," it","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":118,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":119,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":120,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":121,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":122,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":123,"time":1785464640260,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":124,"time":1785487591836,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":125,"time":1785487591836,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82b2fc8c-a12a-46b3-86f5-a8c6ab1140d8"},"usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}},"sourceEventSeqs":[86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124],"surfaceOp":"append"} -{"type":"step/end","seq":126,"time":1785487591836,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":127,"time":1785487591836,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498776226,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"7a222307-4336-4772-8a19-aa1b56558e31"}]}} +{"type":"turn/start","seq":1,"time":1785821381783,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821381783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785078727730,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498776258,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"7a222307-4336-4772-8a19-aa1b56558e31"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730429237,"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":"86a43ffd-fecc-482d-806b-54c13a88c9e5"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730429237,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498776259,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730429239,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1785078728943,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1785078728989,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,1,0,0,48,0,1,0,46,1,0,0,0,0,46,1,0,0,0,0,49,0,1,0,0,0,47,0,0,0,0,1,45,1,0,0,45,1,0,0,140,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," web","_f","etch"," tool"," exactly"," once"," to"," fetch"," http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","seq":54,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":55,"time0":1785078729511,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,46,0,0,1,46,0,0,0,0,1,46,1,0,0,0,0,45,1,105,0],"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","args":["","{","\"","url","\"",": ","\"","http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html","\"","}"]}} +{"type":"assistant/chunk","seq":79,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":80,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1785498776270,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":82,"time":1785730429249,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1785730429249,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"63b78628-921c-4d56-aaa3-ea8e61c54da2"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1785730429250,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} +{"type":"tool/result","seq":85,"time":1785730429278,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"f78dd40c-94c1-4007-b3c2-a8bd3729c43f"},"meta":{"url":"http://127.0.0.1:43117/menu.html","statusCode":200,"truncated":false}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1785730429278,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":87,"time":1785730429288,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":88,"time":1785078730770,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":89,"time0":1785078730824,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,0,36,1,47,47,46,1,0,0,47,0,0,0,0,1,46,43,1,0,0,48,0,46,0,0,0,0,1],"texts":["The"," user"," asked"," me"," to"," fetch"," the"," URL",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," I","'ve"," fetched"," it","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":120,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":121,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":122,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":123,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":124,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":125,"time":1785498776312,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":126,"time":1785730429294,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":127,"time":1785730429294,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"63a38279-bed6-48ff-8420-b8e72839f3be"},"usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}},"sourceEventSeqs":[88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126],"surfaceOp":"append"} +{"type":"step/end","seq":128,"time":1785730429294,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":129,"time":1785730429294,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 559957666c..7b77a09f5e 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,20 +1,22 @@ {"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a7990b4d-9b1d-47de-883b-47f6755c2b9b"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783600636316,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534719523,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"user/message","seq":4,"time":1785534719523,"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"},"role":"user","id":"44d0e7d4-c05c-446d-afd2-28b2a9aec7e8"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785534719523,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785534719523,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785534719524,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":28,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":32,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785464658074,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":34,"time":1785487619708,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":35,"time":1785534719532,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785534719533,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f2b9fe23-12f6-4b47-aaf3-d5ec14a57581"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785534719533,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785534719533,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498800317,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} +{"type":"turn/start","seq":1,"time":1785821416523,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821416523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821416542,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":1785730457309,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730457309,"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":"e076edc0-a2bf-4fc6-aa58-d44bf1e8fd00"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730457309,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":29,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":30,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":34,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":35,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":36,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"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,35,36,37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":1785730457316,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":40,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 1e65c7cf9b..6ee104dd0c 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,32 +1,34 @@ {"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"a1d2f345-c67b-4620-a712-2ac72328fd76"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783600631838,"data":{"title":"Use the workflow tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464657876,"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"},"role":"user","id":"1ef36771-0604-46ae-9264-2b9aa0767c50"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464657876,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464657876,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487619497,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783600635634,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,108,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," workflow"," tool"," exactly"," once"," with"," specific"," parameters","."," Let"," me"," carefully"," follow"," the"," instructions",":\n\n","1","."," args"," omitted"," (","so"," I"," don","'t"," include"," it",")\n","2","."," meta"," ="," {"," \"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\""," }\n","3","."," script"," ="," as"," given"," verb","atim","\n","4","."," After"," it"," returns",","," reply"," with"," \"","WORK","FL","OW","_D","ONE","\"\n\n","Let"," me"," do"," exactly"," that","."]}} -{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":97,"time0":1783600635756,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,5,0,275,0,0,0,0,206,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","args":["","{","\"","meta","\"",": ","{\"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\"","}",", ","\"","script","\"",": ","\"","\\n","phase","('","Run","')\\n","const"," reply"," ="," await"," agent","('","Reply"," with"," exactly"," the"," word"," WF","_CH","ILD","_OK"," and"," nothing"," else",".')\\n","return"," {"," reply"," }\\n","\"","}"]}} -{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} -{"type":"assistant/chunk","seq":159,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} -{"type":"assistant/chunk","seq":160,"time":1785464657891,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} -{"type":"assistant/chunk","seq":161,"time":1785487619510,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":162,"time":1785487619510,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f099c73f-3de3-4190-ab6f-f036910ec57b"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161],"surfaceOp":"append"} -{"type":"tool/call","seq":163,"time":1785487619510,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} -{"type":"tool/result","seq":164,"time":1785487619720,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"2380246b-778b-4bae-afe2-563929b331bd"}},"sourceEventSeqs":[163],"surfaceOp":"append"} -{"type":"step/end","seq":165,"time":1785487619720,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":166,"time":1785487619728,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":167,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":168,"time0":1783600640162,"data":{"turn":1,"step":2,"index":0,"dt":[33,667,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":199,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} -{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":206,"time":1785464658103,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":207,"time":1785487619735,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":208,"time":1785487619735,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f0664b69-ccb9-46b1-9352-da472bd41953"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207],"surfaceOp":"append"} -{"type":"step/end","seq":209,"time":1785487619736,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":210,"time":1785487619736,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498800123,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"}]}} +{"type":"turn/start","seq":1,"time":1785821416248,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"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":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600635634,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,108,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," workflow"," tool"," exactly"," once"," with"," specific"," parameters","."," Let"," me"," carefully"," follow"," the"," instructions",":\n\n","1","."," args"," omitted"," (","so"," I"," don","'t"," include"," it",")\n","2","."," meta"," ="," {"," \"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\""," }\n","3","."," script"," ="," as"," given"," verb","atim","\n","4","."," After"," it"," returns",","," reply"," with"," \"","WORK","FL","OW","_D","ONE","\"\n\n","Let"," me"," do"," exactly"," that","."]}} +{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":99,"time0":1783600635756,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,5,0,275,0,0,0,0,206,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","args":["","{","\"","meta","\"",": ","{\"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\"","}",", ","\"","script","\"",": ","\"","\\n","phase","('","Run","')\\n","const"," reply"," ="," await"," agent","('","Reply"," with"," exactly"," the"," word"," WF","_CH","ILD","_OK"," and"," nothing"," else",".')\\n","return"," {"," reply"," }\\n","\"","}"]}} +{"type":"assistant/chunk","seq":160,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"type":"assistant/chunk","seq":161,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} +{"type":"assistant/chunk","seq":162,"time":1785498800167,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} +{"type":"assistant/chunk","seq":163,"time":1785730457174,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":164,"time":1785730457174,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9a15ecb9-11ce-4d1b-9a0a-07cc388dc0e0"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[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,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163],"surfaceOp":"append"} +{"type":"tool/call","seq":165,"time":1785730457174,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} +{"type":"tool/result","seq":166,"time":1785730457320,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"a3ca6fd6-3d4c-4ad2-a67c-fc9479ef4f15"}},"sourceEventSeqs":[165],"surfaceOp":"append"} +{"type":"step/end","seq":167,"time":1785730457320,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":168,"time":1785730457334,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":169,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":170,"time0":1783600640162,"data":{"turn":1,"step":2,"index":0,"dt":[33,667,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":201,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"assistant/chunk","seq":206,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":207,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":208,"time":1785498800365,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":209,"time":1785730457339,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":210,"time":1785730457339,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"265fc6fa-19e0-4df9-b4ea-f38141ba4efa"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209],"surfaceOp":"append"} +{"type":"step/end","seq":211,"time":1785730457339,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":212,"time":1785730457339,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 0a568d3460..d51a6c820a 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,39 +1,45 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope</system-reminder>/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope</s","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903339799,"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"},"role":"user","id":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"<system-reminder>\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\nRoot snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} -{"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1785464650866,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1785487608779,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6908b415-6aca-462d-9d91-0b27a73ba08c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1785487608779,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"1144df09-c5e0-4781-8734-55acf6f2d4d0"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":1785487608799,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}} -{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1785487608800,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785487608800,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"268c94fb-859c-4ed9-aa98-3a1ccfa31a6f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1785487608801,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}} -{"type":"tool/result","seq":26,"time":1785487608810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"<path>{{cwd}}/scope</system-reminder>/task.txt</path>\n<type>file</type>\n<content>\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"228be9fc-eacf-4a2e-a475-9d4f46b2606d"},"meta":{"path":"{{cwd}}/scope</system-reminder>/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope</system-reminder>\u0000AGENTS.md","path":"scope</system-reminder>/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} -{"type":"step/end","seq":28,"time":1785487608811,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":29,"time":1785487608818,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":30,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":31,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":32,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464650905,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":34,"time":1785487608819,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487608819,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e5f5ae5d-e7fc-47e8-a1af-92fc859e3612"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487608819,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":37,"time":1785487608819,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498790330,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read nested/task.txt, then read scope</system-reminder>/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"22938d3b-c065-46c8-acb7-18f758285842"}]}} +{"type":"turn/start","seq":1,"time":1785821400350,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785498790356,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785901433981,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498790356,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope</system-reminder>/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"22938d3b-c065-46c8-acb7-18f758285842"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785901433982,"data":{"content":[{"type":"text","text":"<system-reminder>\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\nRoot snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"ec039e95-6864-49ef-ad23-4f65b331dc29"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730689193,"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":"cbea9bd9-3e08-48bf-951f-fe3e5aa4b5d9"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730689193,"data":{"title":"Read nested/task.txt, then read scope</s","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498790358,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730689194,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":12,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498790358,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1785730689194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1785730689194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cb229812-91a1-4aa6-8bce-d3b8d032f6dd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1785730689195,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":17,"time":1785730689204,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"260bbd5d-4496-40cf-b987-d1d944d93cf1"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785498790369,"data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","seq":19,"time":1785498790369,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"10cdaa4b-9654-420e-afca-3cfb07e26754"}]}} +{"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"step/start","seq":21,"time":1785730689212,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":22,"time":1785498790377,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"10cdaa4b-9654-420e-afca-3cfb07e26754"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":23,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1785498790377,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}} +{"type":"assistant/chunk","seq":25,"time":1785498790377,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":26,"time":1785498790378,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":27,"time":1785498790378,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":28,"time":1785498790378,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"43385fc1-54b4-4a8a-82ca-d9111c766f48"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"tool/call","seq":29,"time":1785498790378,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}} +{"type":"tool/result","seq":30,"time":1785498790388,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"<path>{{cwd}}/scope</system-reminder>/task.txt</path>\n<type>file</type>\n<content>\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"3a62b23b-d165-4c6d-a028-e171c4b2d7fc"},"meta":{"path":"{{cwd}}/scope</system-reminder>/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1785730689220,"data":{"turn":1,"step":2}} +{"type":"agent/inbox/spliced","seq":32,"time":1785730689220,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"scope</system-reminder>\u0000AGENTS.md","path":"scope</system-reminder>/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"01c3af61-4567-4be2-b776-21f04ddc9cba"}]}} +{"type":"agent/inbox/spliced","seq":33,"time":1785498790389,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"step/start","seq":34,"time":1785498790396,"data":{"turn":1,"step":3}} +{"type":"user/message","seq":35,"time":1785498790396,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"scope</system-reminder>\u0000AGENTS.md","path":"scope</system-reminder>/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"01c3af61-4567-4be2-b776-21f04ddc9cba"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":36,"time":1785498790396,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":37,"time":1785498790396,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":38,"time":1785498790396,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":39,"time":1785498790396,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":40,"time":1785498790397,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":1785498790397,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f82f5200-090f-4e68-a016-963ab7166d4f"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} +{"type":"step/end","seq":42,"time":1785498790397,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":43,"time":1785901434023,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 95c46ed6de..09f05a0659 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,59 +1,61 @@ {"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352264080,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352264081,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"0408c0a7-2344-4d37-9e8e-ab80d42f8062"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352264081,"data":{"title":"A file named greeting.txt in","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464641018,"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"},"role":"user","id":"fc921ed0-60d2-4fc8-94df-7ea6f1b76252"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464641018,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464641018,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487593068,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783352264642,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"time0":1783352264674,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,32,1,1,0,31,0,32,33,0,0,1,29,0,87,1,11,33,1,0,0,0,0,33,1,32,0,1,0,35,1,35,0,0,0,1,0,30,0,0,0,0,1,31,1,0,0,32,1,0,28,66,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," the"," file"," greeting",".txt","\n","2","."," Append"," the"," word"," WORLD"," as"," a"," second"," line","\n","3","."," Read"," the"," file"," back"," with"," cat"," to"," confirm","\n","4","."," Reply"," with"," D","ONE","\n\n","Let"," me"," start"," by"," reading"," the"," file"," to"," see"," its"," contents","."]}} -{"type":"assistant/chunk","seq":63,"time":1783352265326,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":64,"time0":1783352265326,"data":{"turn":1,"step":1,"index":1,"dt":[0,32,0,0,0,33,33,0,0,32,33,0],"id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","args":["","{","\"","file","_path","\"",": ","\"","gre","eting",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":77,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."}}}} -{"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} -{"type":"assistant/chunk","seq":79,"time":1785464641030,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}}}} -{"type":"assistant/chunk","seq":80,"time":1785487593079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":81,"time":1785487593080,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"58e12d0d-6c0e-4efc-b0d0-8004783dfcba"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"} -{"type":"tool/call","seq":82,"time":1785487593080,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":83,"time":1785487593090,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"bf9b0754-3ebc-44b5-8651-efb5292ba959"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[82],"surfaceOp":"append"} -{"type":"step/end","seq":84,"time":1785487593090,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":85,"time":1785487593098,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":86,"time":1783352266550,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":87,"time0":1783352266580,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,29,1,0,32,1,0,0,0,0,32,0,1,32,1,1,0,0,31,1,0,0,0,32,33,30,0,68,0],"texts":["The"," file"," contains"," \"","hello","\""," on"," one"," line","."," Now"," I"," need"," to"," append"," a"," second"," line"," with"," \"","WOR","LD","\""," to"," it","."," Then"," cat"," it"," to"," confirm","."]}} -{"type":"assistant/chunk","seq":119,"time":1783352266932,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":120,"time0":1783352266932,"data":{"turn":1,"step":2,"index":1,"dt":[0,33,0,0,0,33,0,0,37,0,0,0,0,33,49,1,0,0,0,16,0,0,0,33,0,0,32,0,33,1,32,36,1],"id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '\\\\","n","WOR","LD","'"," >>"," greeting",".txt","\"",", ","\"","description","\"",": ","\"","App","end"," new","line"," and"," WORLD"," to"," greeting",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":154,"time":1783352267302,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\" on one line. Now I need to append a second line with \"WORLD\" to it. Then cat it to confirm."}}}} -{"type":"assistant/chunk","seq":155,"time":1783352267302,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}}}} -{"type":"assistant/chunk","seq":156,"time":1785464641057,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":261,"outputTokens":107,"cacheReadTokens":2816,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":157,"time":1785487593104,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":158,"time":1785487593104,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"hello\" on one line. Now I need to append a second line with \"WORLD\" to it. Then cat it to confirm."},{"type":"tool-call","id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e9a8bdb7-e10b-4ffe-bfe4-a9558867f74b"},"usage":{"inputTokens":261,"outputTokens":107,"cacheReadTokens":2816,"reasoningTokens":32}},"sourceEventSeqs":[86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157],"surfaceOp":"append"} -{"type":"tool/call","seq":159,"time":1785487593105,"data":{"turn":1,"step":2,"callId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}} -{"type":"tool/result","seq":160,"time":1785487593123,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_IUUvbNiPcnwhVL8ErEFS4806"},"content":[{"type":"tool-result","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"eb2f2753-3855-42fd-b15e-5d3d493abad2"}},"sourceEventSeqs":[159],"surfaceOp":"append"} -{"type":"step/end","seq":161,"time":1785487593123,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":162,"time":1785487593131,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":163,"time":1783352267872,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":164,"time0":1783352267902,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,34,0,0,0,28,0,0,118,0],"texts":["Good",","," now"," let"," me"," read"," the"," file"," back"," with"," cat","."]}} -{"type":"assistant/chunk","seq":176,"time":1783352268115,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":177,"time0":1783352268115,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0,32,0,0,0,0,66,0,0,0,0,33,1,0,28,1,32,1,31,73,1],"id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","args":["","{","\"","command","\"",": ","\"","cat"," greeting",".txt","\"",", ","\"","description","\"",": ","\"","Read"," greeting",".txt"," to"," confirm","\"","}"]}} -{"type":"assistant/chunk","seq":201,"time":1783352268414,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Good, now let me read the file back with cat."}}}} -{"type":"assistant/chunk","seq":202,"time":1783352268414,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} -{"type":"assistant/chunk","seq":203,"time":1785464641090,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":77,"cacheReadTokens":3072,"reasoningTokens":12}}}} -{"type":"assistant/chunk","seq":204,"time":1785487593138,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":205,"time":1785487593138,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, now let me read the file back with cat."},{"type":"tool-call","id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf1a82c7-d1bb-4d30-9ce4-628099288d3d"},"usage":{"inputTokens":126,"outputTokens":77,"cacheReadTokens":3072,"reasoningTokens":12}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} -{"type":"tool/call","seq":206,"time":1785487593138,"data":{"turn":1,"step":3,"callId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} -{"type":"tool/result","seq":207,"time":1785487593151,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_Wo4H7tFNheZJWKVDrAHK5851"},"content":[{"type":"tool-result","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","content":[{"type":"text","text":"hello\n\nWORLD"}],"isError":false}],"role":"user","id":"a51e96ef-2bbe-4b5b-a7ca-d2c52e3ed993"}},"sourceEventSeqs":[206],"surfaceOp":"append"} -{"type":"step/end","seq":208,"time":1785487593151,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":209,"time":1785487593159,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":210,"time":1783352269291,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":211,"time0":1783352269304,"data":{"turn":1,"step":4,"index":0,"dt":[1,0,0,33,0,32,34,1,0,0,0,32,1,0,33,1,32,1,0,0,0],"texts":["The"," file"," now"," has"," two"," lines",":\n","1","."," hello","\n","2","."," WORLD","\n\n","I"," can"," reply"," with"," D","ONE","."]}} -{"type":"assistant/chunk","seq":233,"time":1783352269505,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":234,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":235,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":236,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines:\n1. hello\n2. WORLD\n\nI can reply with DONE."}}}} -{"type":"assistant/chunk","seq":237,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":238,"time":1785464641115,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":219,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":239,"time":1785487593165,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":240,"time":1785487593165,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file now has two lines:\n1. hello\n2. WORLD\n\nI can reply with DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b151c40b-0fe9-4159-8c74-f06566d09b08"},"usage":{"inputTokens":219,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":22}},"sourceEventSeqs":[210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239],"surfaceOp":"append"} -{"type":"step/end","seq":241,"time":1785487593165,"data":{"turn":1,"step":4}} -{"type":"turn/end","seq":242,"time":1785487593166,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498777332,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"96726dec-a718-4009-ba60-c2b856fe2e6f"}]}} +{"type":"turn/start","seq":1,"time":1785821383408,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821383408,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352264082,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498777358,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"96726dec-a718-4009-ba60-c2b856fe2e6f"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730430363,"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":"ff8d8fb0-6bd9-4484-9406-0548c71cca4f"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730430363,"data":{"title":"A file named greeting.txt in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498777360,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730430364,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352264642,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352264674,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,32,1,1,0,31,0,32,33,0,0,1,29,0,87,1,11,33,1,0,0,0,0,33,1,32,0,1,0,35,1,35,0,0,0,1,0,30,0,0,0,0,1,31,1,0,0,32,1,0,28,66,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Read"," the"," file"," greeting",".txt","\n","2","."," Append"," the"," word"," WORLD"," as"," a"," second"," line","\n","3","."," Read"," the"," file"," back"," with"," cat"," to"," confirm","\n","4","."," Reply"," with"," D","ONE","\n\n","Let"," me"," start"," by"," reading"," the"," file"," to"," see"," its"," contents","."]}} +{"type":"assistant/chunk","seq":65,"time":1783352265326,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":66,"time0":1783352265326,"data":{"turn":1,"step":1,"index":1,"dt":[0,32,0,0,0,33,33,0,0,32,33,0],"id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","args":["","{","\"","file","_path","\"",": ","\"","gre","eting",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":79,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."}}}} +{"type":"assistant/chunk","seq":80,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1785498777370,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}}}} +{"type":"assistant/chunk","seq":82,"time":1785730430374,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1785730430375,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ed0c1fe3-3813-4f27-80b9-325b0b31e51c"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1785730430375,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} +{"type":"tool/result","seq":85,"time":1785730430384,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"8a489ec1-7117-4e95-943e-b0399ff72925"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1785730430384,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":87,"time":1785730430393,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":88,"time":1783352266550,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":89,"time0":1783352266580,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,29,1,0,32,1,0,0,0,0,32,0,1,32,1,1,0,0,31,1,0,0,0,32,33,30,0,68,0],"texts":["The"," file"," contains"," \"","hello","\""," on"," one"," line","."," Now"," I"," need"," to"," append"," a"," second"," line"," with"," \"","WOR","LD","\""," to"," it","."," Then"," cat"," it"," to"," confirm","."]}} +{"type":"assistant/chunk","seq":121,"time":1783352266932,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":122,"time0":1783352266932,"data":{"turn":1,"step":2,"index":1,"dt":[0,33,0,0,0,33,0,0,37,0,0,0,0,33,49,1,0,0,0,16,0,0,0,33,0,0,32,0,33,1,32,36,1],"id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '\\\\","n","WOR","LD","'"," >>"," greeting",".txt","\"",", ","\"","description","\"",": ","\"","App","end"," new","line"," and"," WORLD"," to"," greeting",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":156,"time":1783352267302,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\" on one line. Now I need to append a second line with \"WORLD\" to it. Then cat it to confirm."}}}} +{"type":"assistant/chunk","seq":157,"time":1783352267302,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":158,"time":1785498777395,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":261,"outputTokens":107,"cacheReadTokens":2816,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":159,"time":1785730430399,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":160,"time":1785730430399,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"hello\" on one line. Now I need to append a second line with \"WORLD\" to it. Then cat it to confirm."},{"type":"tool-call","id":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8d453336-0eaa-434e-a5bd-fe8aa38fac1c"},"usage":{"inputTokens":261,"outputTokens":107,"cacheReadTokens":2816,"reasoningTokens":32}},"sourceEventSeqs":[88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} +{"type":"tool/call","seq":161,"time":1785730430399,"data":{"turn":1,"step":2,"callId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","name":"bash","arguments":"{\"command\": \"printf '\\\\nWORLD' >> greeting.txt\", \"description\": \"Append newline and WORLD to greeting.txt\"}"}} +{"type":"tool/result","seq":162,"time":1785730430417,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_IUUvbNiPcnwhVL8ErEFS4806"},"content":[{"type":"tool-result","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"8a676c82-0658-4da3-a139-99734100c860"}},"sourceEventSeqs":[161],"surfaceOp":"append"} +{"type":"step/end","seq":163,"time":1785730430417,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":164,"time":1785730430425,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":165,"time":1783352267872,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":166,"time0":1783352267902,"data":{"turn":1,"step":3,"index":0,"dt":[1,0,34,0,0,0,28,0,0,118,0],"texts":["Good",","," now"," let"," me"," read"," the"," file"," back"," with"," cat","."]}} +{"type":"assistant/chunk","seq":178,"time":1783352268115,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":179,"time0":1783352268115,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0,32,0,0,0,0,66,0,0,0,0,33,1,0,28,1,32,1,31,73,1],"id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","args":["","{","\"","command","\"",": ","\"","cat"," greeting",".txt","\"",", ","\"","description","\"",": ","\"","Read"," greeting",".txt"," to"," confirm","\"","}"]}} +{"type":"assistant/chunk","seq":203,"time":1783352268414,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Good, now let me read the file back with cat."}}}} +{"type":"assistant/chunk","seq":204,"time":1783352268414,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} +{"type":"assistant/chunk","seq":205,"time":1785498777425,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":77,"cacheReadTokens":3072,"reasoningTokens":12}}}} +{"type":"assistant/chunk","seq":206,"time":1785730430430,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":207,"time":1785730430430,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, now let me read the file back with cat."},{"type":"tool-call","id":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"daa2cdd5-7f59-4e28-af51-5c7f0864ef1d"},"usage":{"inputTokens":126,"outputTokens":77,"cacheReadTokens":3072,"reasoningTokens":12}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"} +{"type":"tool/call","seq":208,"time":1785730430430,"data":{"turn":1,"step":3,"callId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} +{"type":"tool/result","seq":209,"time":1785730430442,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_Wo4H7tFNheZJWKVDrAHK5851"},"content":[{"type":"tool-result","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","content":[{"type":"text","text":"hello\n\nWORLD"}],"isError":false}],"role":"user","id":"6f505561-34d1-4648-9b58-e0b412a06b59"}},"sourceEventSeqs":[208],"surfaceOp":"append"} +{"type":"step/end","seq":210,"time":1785730430442,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":211,"time":1785730430452,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":212,"time":1783352269291,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":213,"time0":1783352269304,"data":{"turn":1,"step":4,"index":0,"dt":[1,0,0,33,0,32,34,1,0,0,0,32,1,0,33,1,32,1,0,0,0],"texts":["The"," file"," now"," has"," two"," lines",":\n","1","."," hello","\n","2","."," WORLD","\n\n","I"," can"," reply"," with"," D","ONE","."]}} +{"type":"assistant/chunk","seq":235,"time":1783352269505,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":236,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":237,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":238,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines:\n1. hello\n2. WORLD\n\nI can reply with DONE."}}}} +{"type":"assistant/chunk","seq":239,"time":1783352269538,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":240,"time":1785498777450,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":219,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":241,"time":1785730430457,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":242,"time":1785730430457,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file now has two lines:\n1. hello\n2. WORLD\n\nI can reply with DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c8f9ddb-8946-494f-9249-9633e56482dd"},"usage":{"inputTokens":219,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241],"surfaceOp":"append"} +{"type":"step/end","seq":243,"time":1785730430457,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":244,"time":1785730430457,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index c4cfb23631..de50cce818 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: 445804a2611e5e8093eadf345ad10a2a7984c012 -README.zh.md: 956bc82e77f79c3f05e4b51297fd5365e6e89be1 +README.md: 670a91ab402cf98585f2ece70787beb3b4aaf4dc +README.zh.md: 6c8b3b5694403c5e09f2904f5c3ca18fe569163e diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 445804a261..670a91ab40 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -19,8 +19,8 @@ Exactly one nonblank positional task is required; quote tasks containing spaces. Each invocation creates and persists a fresh session, runs all model and tool steps in one turn, flushes, disposes, and exits. This is non-interactive automation: there is no prompt, approval, resume, second turn, or stdin context. The configured tools can mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. -## Advanced and snapshot wiring +## Advanced configuration -[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the shipped leaf. [`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) replaces only the live LLM with replay. The tests under [`tests/`](tests/) own the keyless real-Loader smoke, key-gated world-verified smoke, and the `stream-json` replay snapshot with its parent and child session fixtures. +[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the shipped leaf. The package-level [CLI contract](../../packages/examples/cli-demo/README.md) documents output records, exit status, cancellation, persistence, and model/token effects. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 956bc82e77..6c8b3b5694 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -19,8 +19,8 @@ pnpm run demo:headless --output-format stream-json -- "run the focused tests" 每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷写持久化数据、执行 dispose(资源释放),再退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动时所在的工作区、运行命令、spawn 子 agent,并消耗提供方 token。 -## 高级与快照接线 +## 高级配置 -[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下涵盖无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 +[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。 -这份包(package)级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。 +这份包级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。 diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 1f708ab601..648e77156c 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -57,6 +58,7 @@ async function codeModeHarness(cwd: string): Promise<Context> { await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -117,6 +119,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise<Context> { await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness @@ -354,7 +357,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p expect(finalText).toContain('beta-9') }, 180_000) - it('delivers nested workspace instructions discovered by an fs sub-call after the outer result', async () => { + it('projects nested workspace instructions discovered by an fs sub-call', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-')) await mkdir(join(workdir, '.git'), { recursive: true }) await mkdir(join(workdir, 'pkg/deep'), { recursive: true }) @@ -377,16 +380,21 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') - const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'workspace-instructions') + const workspaceContext = await vi.waitFor(() => { + const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.source.kind === 'workspace-instructions')) + const inserted = splice?.type === 'agent/inbox/spliced' + ? splice.data.inserted.find(message => message.source.kind === 'workspace-instructions') + : undefined + expect(inserted).toBeDefined() + return inserted! + }) expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() - expect(workspaceContext).toBeDefined() - expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq) - const finalMessage = events.findLast(event => event.type === 'assistant/message') - const answer = finalMessage?.type === 'assistant/message' - ? finalMessage.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - : '' - expect(answer).toContain(WORKSPACE_PROBE) + const contextText = workspaceContext.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + expect(contextText).toContain(WORKSPACE_PROBE) }, 180_000) }) diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index cb0e072d2e..de64e4599b 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,11 +7,13 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/step', (agent) => { - if (ctx.goals.get(agent) !== undefined) return - ctx.goals.create(agent, { - objective: 'Prove the composed goal survives in the session log', - maxGoalRounds: 7, - }) + ctx.on('agent/pre-step', (agent, _messages, _context, next) => { + if (ctx.goals.get(agent) === undefined) { + ctx.goals.create(agent, { + objective: 'Prove the composed goal survives in the session log', + maxGoalRounds: 7, + }) + } + return next() }) } diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index c354205388..756cc58e39 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -61,6 +62,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 8b48165a83..eb39254dac 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -223,7 +223,7 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('surfaces actionable missing-credential guidance through the one-shot app', async () => { + 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({ @@ -239,22 +239,19 @@ describe('headless stream-json snapshots', () => { DEEPSEEK_BASE_URL: '', NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, - // The designed failure surface: the one-shot app reports the failed turn. - expectedExitCode: 1, prepare: (cwd) => { runCwd = cwd }, }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and offers a literal key last. - expect(result.stderr).toBe( - 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek-official";' - + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' - + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' - + ' "apiKey" in the llm-deepseek settings section\n', - ) + 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 — and offers a literal key last. + expect(normalized).toContain( + 'store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),', + ) + expect(normalized).toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('logs the model default and a dynamic next-step reasoning effort', async () => { @@ -461,19 +458,11 @@ describe('headless stream-json snapshots', () => { 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) => { - if (record.type !== 'user/message') return false - const data = record.data as JsonObject | undefined - const source = data?.source as JsonObject | undefined - const change = source?.change as JsonObject | undefined - return source?.kind === 'goal' && change?.kind === 'goal/change' - }) + const goalChanges = records.filter(record => record.type === 'goal/change') expect(goalChanges).toHaveLength(1) const data = goalChanges[0]?.data as JsonObject | undefined - const source = data?.source as JsonObject | undefined - const change = source?.change as JsonObject | undefined - const goal = change?.goal as JsonObject | undefined - expect(change?.operation).toBe('create') + 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', diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4e18e177c3..2547d04ac1 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -39,8 +39,7 @@ describe('headless-agent keyless smoke', () => { expect(stderr).toBe('') expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) const catalogMessage = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') const catalog = catalogMessage?.type === 'user/message' ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') : '' @@ -53,12 +52,9 @@ describe('headless-agent keyless smoke', () => { expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') expect(result).toMatchObject({ type: 'result', - success: true, - turn: 1, - reason: { kind: 'completed' }, usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 }, }) - expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP') + 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 index 192f62bc09..7af81fa449 100644 --- 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 @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"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"} @@ -8,16 +8,18 @@ {"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":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":10,"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":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"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":"request/header","seq":13,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":14,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":16,"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":17,"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":18,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":19,"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":[15,16,17,18],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":21,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"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 index 92ce72f4e7..de41799b76 100644 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts @@ -32,7 +32,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise<string delegationDepth: 0, } const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { 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' }, @@ -112,10 +112,8 @@ describe('semantic checkpoint recovery snapshot', () => { const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>) expect(records.at(-1)).toMatchObject({ type: 'result', - success: true, sessionId, - result: 'I will verify the external state before deciding whether to retry the side-effecting operation.', - reason: { kind: 'completed' }, + 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/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index bc159473de..10c380057d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,16 +1,18 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"03d6fc5d-0017-428d-b187-bc09a674c676"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534749329,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"step/start","seq":4,"time":1785534749330,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749330,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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. The subagent does not reply to you, so use this only 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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"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. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"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":6,"time":1785534749330,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":10,"time":1785460681625,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":11,"time":1785534749331,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":12,"time":1785534749331,"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":"800f82e0-4aff-47ad-86e8-251846b1fc54"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":1785534749331,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":14,"time":1785534749331,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index a6f1bfc4c7..d514d19b96 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,16 +1,18 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8086a738-4c3a-4ad0-8fa2-a34867a776bb"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534749466,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785534749467,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749468,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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. The subagent does not reply to you, so use this only 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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"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. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"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":6,"time":1785534749468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":10,"time":1785460681788,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":11,"time":1785534749468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":12,"time":1785534749468,"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":"136e73a6-f11b-4dc9-9818-46e3960a9b76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":1785534749468,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":14,"time":1785534749468,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 255a590d97..fe5fa4dc8d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,67 +1,69 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"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":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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. The subagent does not reply to you, so use this only 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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"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. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"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":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":10,"time":1785460681499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785460681499,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7398afc6-de92-474e-8bc8-86541cb45337"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785460681499,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":13,"time":1785460681510,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"28feb408-1027-4c8a-8271-c2201c74c341"}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785460681510,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1785460681520,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":20,"time":1785460681521,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":1785460681521,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"65a64503-71f3-454f-bc17-8ce8aa9f0cf8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"tool/call","seq":22,"time":1785460681521,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":23,"time":1785460681591,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":24,"time":1785460681591,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":25,"time":1785460681594,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"af85da66-7dce-4eaa-900b-7ad0f92a7c65"}},"sourceEventSeqs":[22],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785460681594,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":1785460681600,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1783950000029,"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.\"}"}}} -{"type":"assistant/chunk","seq":30,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":1785460681601,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":1785460681601,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e13f668d-e5b7-4cdd-ba1b-4ff1fdb91452"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":1785460681602,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":1785460681634,"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":"6e5fd0bc-71db-4b30-a722-e8565bde142f"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785460681634,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":1785460681644,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1783957884594,"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":40,"time":1783957884594,"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":41,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":1785460681645,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":1785460681645,"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":"e6b188f7-59ea-425f-8f94-67004084a67a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":1785460681645,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":45,"time":1785460681798,"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":"70ac2de1-6cb8-4702-a532-5acdcb108cde"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":1785460681798,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":1785460681805,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":1785460681806,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":1785460681806,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"89f22db0-7517-45a2-bf11-4ddfcf742119"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":1785460681806,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":1785460681814,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"f3b59433-31e1-40ae-a660-e006b28d59e8"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":1785460681814,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":1785460681821,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} -{"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"assistant/chunk","seq":61,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":62,"time":1785460681822,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":63,"time":1785460681822,"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":"acc4aec8-506b-4592-aff9-3370e6c559ef"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[58,59,60,61,62],"surfaceOp":"append"} -{"type":"step/end","seq":64,"time":1785460681822,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":65,"time":1785460681823,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\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> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"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\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":31,"time":1783950000030,"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.\"}"}}} +{"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":41,"time":1783957884594,"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":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} +{"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} +{"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 456d6c0a39..c342059741 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -1,67 +1,69 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"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":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"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":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"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":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":25,"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":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"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":29,"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.\"}"}}}} -{"type":"session_event","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":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"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":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":33,"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.\"}"}],"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"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":34,"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.\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":35,"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":[34],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"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":39,"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":40,"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":41,"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":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":43,"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":[38,39,40,41,42],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":44,"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/result","seq":45,"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":[44],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"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":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} -{"type":"session_event","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":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"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":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"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":59,"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":60,"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":61,"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":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":63,"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":[58,59,60,61,62],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":64,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":65,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_HEADLESS_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"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-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', 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-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', 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-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', 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-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', 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-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{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":"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\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"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\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} +{"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\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"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\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"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":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"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":31,"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.\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"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.\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"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":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":35,"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.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":36,"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.\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":37,"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":[36],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":38,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":39,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"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":41,"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":42,"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":43,"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":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":45,"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":[40,41,42,43,44],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":46,"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/result","seq":47,"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":[46],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":48,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":49,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"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":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"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":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":57,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[56],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":58,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":59,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"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":61,"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":62,"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":63,"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":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":65,"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":[60,61,62,63,64],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":67,"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/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index ff94280aa1..cf36d97e79 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -1,46 +1,48 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"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":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"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":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":24,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[33],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":36,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":44,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Probe strict-schema fillers against miss","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_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}} +{"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_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"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_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"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_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"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":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"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":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"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":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"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":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":25,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":26,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"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":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"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":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"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":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4}},"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":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"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":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"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":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":44,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":45,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":46,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"GOAL READY","usage":{"inputTokens":100,"outputTokens":20}} diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index 4f3bcd2321..16a690dd88 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -1,9 +1,12 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"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":"say pong"}],"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":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} -{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","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","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"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":1000000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl index 8c6601b35d..ca523f3665 100644 --- a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -1,20 +1,19 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"retry the transient provider failure"}],"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":7,"time":0,"data":{"turn":1,"step":1,"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}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":8,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"retry"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":10,"time":0,"data":{"turn":2,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":2,"result":"RETRY_OK","reason":{"kind":"completed"},"usage":{"inputTokens":4,"outputTokens":2}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"retry the transient provider failure","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":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"turn":1,"step":1,"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}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 19cd2955d2..ad7c100179 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,76 +1,78 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2d9e550e-6aff-4ec5-b059-887bd34a456c"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464685153,"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"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"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":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"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":9,"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":10,"time":1785464685155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785487564326,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487564326,"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":"b03f1f00-1432-41c6-8dbb-c8862bf8e45a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487564326,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":14,"time":1785487564336,"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":"e55df4b9-6eae-411c-8d93-2d1e22e96088"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487564336,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487564343,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"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":19,"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":20,"time":1785464685175,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785487564345,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487564345,"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":"0695daf5-0341-4e64-a8e2-e436595b30c7"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487564345,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":24,"time":1785487564352,"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":"66d32fdd-7471-4112-9df9-1e846675d2f1"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785487564352,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785487564359,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"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":29,"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":30,"time":1785464685190,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":31,"time":1785487564360,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785487564360,"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":"1f204a7d-f757-474d-bcb0-d41a721ec500"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785487564361,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":34,"time":1785487564368,"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":"fbff70b9-9350-4b3d-a7c2-960c55f16d95"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785487564369,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":36,"time":1785487564375,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"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":39,"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":40,"time":1785464685207,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":41,"time":1785487564377,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":42,"time":1785487564377,"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":"6ad41c1e-2823-41ab-a3de-e4c9413840e7"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} -{"type":"tool/call","seq":43,"time":1785487564377,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":44,"time":1785487564384,"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":"7ac26b80-eb10-49ed-acf3-a665b9dd7ee3"}},"sourceEventSeqs":[43],"surfaceOp":"append"} -{"type":"step/end","seq":45,"time":1785487564385,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":46,"time":1785487564392,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"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":49,"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":50,"time":1785464685224,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":51,"time":1785487564393,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785487564393,"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":"c81bbc58-853a-4143-b6cb-edd4301e042c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785487564393,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":54,"time":1785487564400,"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":"d3704a32-31e0-4251-bbe5-54a751c91102"}},"sourceEventSeqs":[53],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1785487564400,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":56,"time":1785487564407,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":58,"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":59,"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":60,"time":1785464685239,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":61,"time":1785487564409,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":62,"time":1785487564409,"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":"04bb9b2b-f9f0-4c47-a1f1-046d985ed826"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} -{"type":"tool/call","seq":63,"time":1785487564409,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":64,"time":1785487564415,"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":"79f388b8-88c4-412f-a8da-3465bb643f5f"}},"sourceEventSeqs":[63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487564415,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":66,"time":1785487564422,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":70,"time":1785464685256,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":71,"time":1785487564424,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":72,"time":1785487564424,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8b8d69b6-3103-4882-9031-c55f7f004188"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[67,68,69,70,71],"surfaceOp":"append"} -{"type":"step/end","seq":73,"time":1785487564424,"data":{"turn":1,"step":7}} -{"type":"turn/end","seq":74,"time":1785487564424,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"}]}} +{"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}]},"role":"user","id":"a5ae9c04-0652-436f-9b5a-437a3a6ed235"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8cad9650-de5a-4075-8aa3-1b35e67efc2d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} +{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"1ba1f641-0cf2-496c-895e-3982aa40b0ed"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"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":"f5850535-906d-4a78-8518-a733ec91bbd8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} +{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"4f19b0d0-b31d-40b7-84e5-2783131cb363"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"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":"4e0d8eed-877d-4a5b-bd92-e7c4c8f2cf23"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} +{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"72be20be-6022-4363-b9bb-1d9f2cce0e20"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"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":"87c2857f-5da3-4050-b081-e044e207be88"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} +{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"c6e3ff43-809c-4bd8-ba88-da7294c3385a"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"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":"5b53e0d5-1c68-4988-9fab-d885a9122fe8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} +{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"20e3fe4d-3d9d-4771-b75d-1f287fc20048"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"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":"178fb9bc-859c-484d-8376-096a705de30a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} +{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"584673b3-9221-4a42-b9e4-69ce1b9f4d60"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"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":"23d457ba-b690-4bb9-b434-86f43c9f4da5"},"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 index 2ad81eef02..2ef42323fb 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl @@ -1,76 +1,78 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"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":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":3,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":5,"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":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"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":8,"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":9,"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":10,"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":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":12,"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":[7,8,9,10,11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":13,"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":14,"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":[13],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":16,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"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":18,"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":19,"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":20,"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":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"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":[17,18,19,20,21],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":23,"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":24,"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":[23],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"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":29,"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":30,"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":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":32,"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":[27,28,29,30,31],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":33,"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":34,"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":[33],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":36,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"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":38,"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":39,"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":40,"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":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":42,"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":[37,38,39,40,41],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":43,"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":44,"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":[43],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":45,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":46,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"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":48,"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":49,"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":50,"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":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"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":[47,48,49,50,51],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"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":[53],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"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":59,"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":60,"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":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":62,"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":[57,58,59,60,61],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":63,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":64,"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":[63],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":66,"time":0,"data":{"turn":1,"step":7}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"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":68,"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":69,"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":70,"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":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":72,"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":[67,68,69,70,71],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":73,"time":0,"data":{"turn":1,"step":7}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":74,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"DONE","reason":{"kind":"completed"},"usage":{"inputTokens":70,"outputTokens":33}} +{"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/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl index 8d0e2aac10..fe2c9df583 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl @@ -1,25 +1,27 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run 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":2,"time":0,"data":{"title":"Run a two-round fresh-agent Ralph","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"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":8,"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":9,"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":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":11,"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":[6,7,8,9,10],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":12,"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":13,"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":[12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"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":17,"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":18,"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":19,"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":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"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":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"RALPH SNAPSHOT COMPLETE","reason":{"kind":"completed"},"usage":{"inputTokens":50,"outputTokens":12}} +{"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/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index de0e1a17ce..dbcd59cc5a 100644 --- 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 @@ -1,28 +1,30 @@ {"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":"turn/start","seq":1,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":2,"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":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} -{"type":"session/title","seq":4,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[2],"source":{"kind":"fallback"}}} -{"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: 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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":0,"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":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} -{"type":"assistant/chunk","seq":11,"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":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"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":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} -{"type":"tool/result","seq":16,"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":[15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"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":21,"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":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"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":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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."}],"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":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/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl index 28e018eb2e..0bdcc94938 100644 --- 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 @@ -1,31 +1,33 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"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":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"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: 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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}} -{"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":"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":14,"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":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":"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":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"tool/call","seq":18,"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":19,"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":[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 delegated child was denied by the sandbox. 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 delegated child was denied by the sandbox. 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 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":[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"}}} +{"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 index a94e9716d3..e5f6994d0c 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -39,7 +39,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise<void> { delegationDepth: 0, } const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { 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' } } }, @@ -135,10 +135,8 @@ describe('parent-only override inheritance snapshot', () => { const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>) expect(records.at(-1)).toMatchObject({ type: 'result', - success: true, sessionId, - result: 'The delegated child was denied by the sandbox. PARENT_DONE', - reason: { kind: 'completed' }, + output: 'The delegated child was denied by the sandbox. PARENT_DONE', }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 75a6ed038e..ce35a749a0 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: 5e7b7d79415a4af0b0d16c61b0a4590dce57e545 -README.zh.md: 00450838e7d10ae9e95624b5f89992dd747d5440 +README.md: bcc1027d2edb30ab374dfa2ed13ad8e6360d923b +README.zh.md: 197c25d7b4f5645aeb7e92d8c36ef4a423beb2e8 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 5e7b7d7941..bcc1027d2e 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -33,8 +33,4 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless SDK snapshot drives the shipped JSON-RPC runtime through both tools, proves that shell cwd/environment survive across calls, and pins the notification stream, turn result, and persisted JSONL: - -```bash -pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools -``` +It composes the local PTY, filesystem intent policy, and session sandbox policy. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index 00450838e7..197c25d7b4 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -33,8 +33,4 @@ - agent 独占、状态持久的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥 SDK 快照会通过正式 JSON-RPC runtime 驱动这两个工具,验证 shell 的 cwd 与环境变量能跨调用保留,并锁定通知流、轮次结果与已持久化的 JSONL: - -```bash -pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools -``` +它组合本地 PTY、文件系统 intent 策略与会话沙箱策略。 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index a713b27163..e2e038d801 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -47,10 +47,10 @@ function waitForLine( describe('jsonrpc-agent keyless smoke', () => { it.each([ - { label: 'accepts max-token results by default', envValue: undefined, expectedStatus: 'ok' }, - { label: 'accepts max-token results when enabled through env', envValue: 'true', expectedStatus: 'ok' }, - { label: 'reports max-token results as errors when disabled through env', envValue: 'false', expectedStatus: 'error' }, - ])('$label', async ({ envValue, expectedStatus }) => { + { 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<string, unknown>[] = [] const modelServer = createServer((request, response) => { @@ -120,18 +120,29 @@ describe('jsonrpc-agent keyless smoke', () => { method: 'session/prompt', params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, })}\n`) - const finished = await waitForLine(lines, value => value.method === 'session.finished', () => stderr) - expect(finished).toMatchObject({ + const prompt = await waitForLine(lines, value => value.id === 2, () => stderr) + expect(prompt).toMatchObject({ jsonrpc: '2.0', - method: 'session.finished', + 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<string, unknown> | undefined + const event = params?.event as Record<string, unknown> | undefined + return params?.sessionId === 'main' && event?.type === 'turn/end' + }, () => stderr) + expect(turnEnd).toMatchObject({ + jsonrpc: '2.0', + method: 'session.event', params: { sessionId: 'main', - status: expectedStatus, - reason: { kind: 'max-tokens' }, + event: { + type: 'turn/end', + data: { reason: { kind: 'max-tokens' } }, + }, }, }) - const prompt = await waitForLine(lines, value => value.id === 2, () => stderr) - expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } }) 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([ diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index a8f7e3864a..0bef713571 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -2,7 +2,7 @@ * Keyless snapshot coverage for the TypeScript SDK path: each scenario spawns * the REAL `dsh-jsonrpc-agent` runtime (per `DSH_EXAMPLE_MODE`) through the * REAL `@deepseek-ai/dsh-sdk-client`, drives one turn over stdio JSON-RPC, - * and pins three surfaces — the SDK `TurnResult`, the complete notification + * and pins three surfaces — 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 @@ -25,7 +25,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -import { DeepSeekHarness, type HarnessNotification, type TurnResult } from '@deepseek-ai/dsh-sdk-client' +import { DeepSeekHarness, type HarnessNotification, type RunResult } from '@deepseek-ai/dsh-sdk-client' const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') @@ -217,18 +217,17 @@ function normalizeNotifications(notifications: readonly HarnessNotification[], c return normalizeStdout(`${records.map(record => JSON.stringify(record)).join('\n')}\n`, ctx) } -/** Normalize the turn-result projection (status, reason kind, final text). */ -function normalizeResult(result: TurnResult, ctx: NormalizeContext): string { +/** Normalize the owned-run projection. */ +function normalizeResult(result: RunResult, ctx: NormalizeContext): string { return normalizeStdout(`${JSON.stringify({ - status: result.status, - reason: result.reason, + 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: TurnResult + result: RunResult notifications: HarnessNotification[] logs: PersistedLog[] observedFiles: Record<string, string | MissingFile> @@ -381,8 +380,10 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(normalizedResult).toBe(await readFile(resultExpectedPath, 'utf8')) // Wire-shape invariants that must hold in every mode. - expect(result.status).toBe('ok') - expect(notifications.at(-1)?.method).toBe('session.finished') + expect(notifications.at(-1)).toMatchObject({ + method: 'session.status', + params: { status: 'idle' }, + }) expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) if (scenario.expectedTools !== undefined) { const parent = ordered[0] diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl index e777e93585..4f2601b62c 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl @@ -1,98 +1,101 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"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":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":19,"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":20,"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":21,"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":22,"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":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"{"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} -{"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":"command"}}}}} +{"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":": "}}}}} +{"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":"echo"}}}}} -{"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":" d"}}}}} -{"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":"sh"}}}}} -{"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":"-s"}}}}} -{"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":"dk"}}}}} -{"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":"-proof"}}}}} -{"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":"-"}}}}} -{"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":"739"}}}}} -{"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":"1"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":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":"description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":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":"Run"}}}}} -{"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":" the"}}}}} -{"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":" echo"}}}}} -{"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":" command"}}}}} -{"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":" as"}}}}} -{"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":" requested"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"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":57,"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":58,"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":59,"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":60,"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":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":61,"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":62,"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":[61],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"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":66,"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":67,"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":68,"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":69,"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":70,"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":71,"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":72,"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":73,"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":74,"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":75,"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":76,"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":77,"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":78,"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":79,"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":80,"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":81,"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":82,"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":83,"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":84,"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":85,"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":86,"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":87,"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":88,"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":89,"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":90,"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":91,"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":92,"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":93,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":94,"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":[65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":95,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":96,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} +{"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/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json index 42b553d4a3..6f78073db3 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json @@ -1 +1 @@ -{"status":"ok","reason":{"kind":"completed"},"finalResponse":"dsh-sdk-proof-7391"} +{"sessionId":"{{sessionId}}","finalResponse":"dsh-sdk-proof-7391"} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index 05bbc0a6a9..c2ea52eb1b 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -1,31 +1,33 @@ {"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"23b8771a-947e-4efd-b9bd-786afdf008eb"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"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":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785460687755,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":6,"time":1785097396438,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":7,"time0":1785097396657,"data":{"turn":1,"step":1,"index":0,"dt":[22,1,0,0,0,1,24,25,0,0,25,1,24,1,0,75],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} -{"type":"assistant/chunk","seq":24,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":25,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[0,24,1,0,0,25,0,0,0,0,1,24,0,0,1,24,1,25,0,0,0,25,0,0,25,1,0,0,25,55],"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":56,"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":57,"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":58,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":59,"time":1785460687766,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":60,"time":1785460687766,"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":"577d757a-548b-4272-887b-153d6bd49b7e"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59],"surfaceOp":"append"} -{"type":"tool/call","seq":61,"time":1785460687766,"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":62,"time":1785460687784,"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":"0ddd8391-09c7-4907-bb88-78b69f27f69b"}},"sourceEventSeqs":[61],"surfaceOp":"append"} -{"type":"step/end","seq":63,"time":1785460687784,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":64,"time":1785460687793,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":65,"time":1785097398037,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":66,"time0":1785097398255,"data":{"turn":1,"step":2,"index":0,"dt":[25,1,0,24,1,0,0,25,0,0,26,1,0,0],"texts":["The"," command"," produced"," the"," expected"," output","."," I","'ll"," reply"," with"," just"," that"," stdout","."]}} -{"type":"assistant/chunk","seq":81,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":82,"time0":1785097398358,"data":{"turn":1,"step":2,"index":1,"dt":[24,0,0,0,1,0,25],"texts":["d","sh","-s","dk","-proof","-","739","1"]}} -{"type":"assistant/chunk","seq":90,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}} -{"type":"assistant/chunk","seq":91,"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":92,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}} -{"type":"assistant/chunk","seq":93,"time":1785460687799,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1785460687799,"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":"22f555fd-544e-4f41-9201-7226855af452"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1785460687799,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":96,"time":1785460687799,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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 index 05177d3467..ea8f220308 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,76 +1,79 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"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":2,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":3,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"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":"request/header","seq":5,"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":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"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":8,"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":9,"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":10,"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":11,"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":12,"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":[7,8,9,10,11],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":13,"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":14,"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":[13],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":16,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"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":18,"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":19,"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":20,"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":21,"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":22,"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":[17,18,19,20,21],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":23,"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":24,"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":[23],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"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":28,"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":29,"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":30,"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":31,"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":32,"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":[27,28,29,30,31],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":33,"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":34,"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":[33],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":36,"time":0,"data":{"turn":1,"step":4}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"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":38,"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":39,"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":40,"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":41,"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":42,"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":[37,38,39,40,41],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":43,"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":44,"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":[43],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":45,"time":0,"data":{"turn":1,"step":4}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":46,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"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":48,"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":49,"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":50,"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":51,"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":52,"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":[47,48,49,50,51],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"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":54,"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":[53],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"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":58,"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":59,"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":60,"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":61,"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":62,"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":[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":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":64,"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":[63],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":66,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"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":68,"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":69,"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":70,"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":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":72,"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":[67,68,69,70,71],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":73,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":74,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} +{"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":"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"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"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":7,"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":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":[9,10,11,12,13],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"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":16,"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":[15],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"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":20,"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":21,"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":22,"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":23,"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":24,"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":[19,20,21,22,23],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":25,"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":26,"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":[25],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":28,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"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":30,"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":31,"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":32,"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":33,"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":34,"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":[29,30,31,32,33],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":35,"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":36,"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":[35],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"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":40,"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":41,"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":42,"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":43,"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":44,"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":[39,40,41,42,43],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":45,"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":46,"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":[45],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":47,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":48,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"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":50,"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":51,"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":52,"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":53,"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":54,"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":[49,50,51,52,53],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":55,"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":56,"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":[55],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":57,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":58,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"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":60,"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":61,"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":62,"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":63,"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":64,"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":[59,60,61,62,63],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":65,"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":66,"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":[65],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":67,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":68,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"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":70,"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":71,"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":72,"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":73,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":74,"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":[69,70,71,72,73],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":75,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":76,"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/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json index 989372e15b..53a1342f7d 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json @@ -1 +1 @@ -{"status":"ok","reason":{"kind":"completed"},"finalResponse":"PERSISTENT_TOOLS_OK"} +{"sessionId":"{{sessionId}}","finalResponse":"PERSISTENT_TOOLS_OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index 3bc2fe0ded..be559eeb9a 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,76 +1,78 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"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":"1c1694de-4c1a-4cff-b1b4-840da87458ba"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785464687947,"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"},"role":"user","id":"2cb82732-0193-4b9b-b63d-05694833a86f"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785464687947,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464687948,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785487571156,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1785331618326,"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":9,"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":10,"time":1785464687949,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":11,"time":1785487571157,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785487571157,"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":"f391e0e6-0f83-4976-b6ad-49f46753de4d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785487571157,"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":14,"time":1785487571639,"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":"ff941cf3-2c1b-431e-82de-499133341fca"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785487571639,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785487571639,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1785331618652,"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":19,"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":20,"time":1785464688888,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":21,"time":1785487571640,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785487571640,"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":"8bfc9fe6-6508-4b66-bd7e-984d93103d2f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785487571640,"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":24,"time":1785487571747,"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":"0cb7f395-c1bf-4284-af01-495dd74de8cd"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785487571748,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785487571748,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"time":1785331618762,"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":29,"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":30,"time":1785464688996,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":31,"time":1785487571748,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785487571748,"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":"ac4fcd98-0819-46fa-8669-8cb6a2159c42"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785487571749,"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":34,"time":1785487571760,"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":"bbdac66e-3aa2-403f-989a-a84e79225647"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785487571760,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":36,"time":1785487571761,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1785331618784,"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":39,"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":40,"time":1785464689011,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":41,"time":1785487571762,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":42,"time":1785487571762,"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":"e0d3243d-4ff7-4700-aa16-881b7fed18d6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} -{"type":"tool/call","seq":43,"time":1785487571763,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} -{"type":"tool/result","seq":44,"time":1785487571765,"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":"8615a23b-572b-45f0-aa65-aefd01c0d7cb"}},"sourceEventSeqs":[43],"surfaceOp":"append"} -{"type":"step/end","seq":45,"time":1785487571765,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":46,"time":1785487571765,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"time":1785331618801,"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":49,"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":50,"time":1785464689013,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":51,"time":1785487571766,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785487571766,"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":"6a2fb3ba-40fc-46d1-afa8-36ccced13ca5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785487571766,"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":54,"time":1785487571779,"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":"bd958cbb-843c-4971-a352-1c051677e8df"}},"sourceEventSeqs":[53],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1785487571780,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":56,"time":1785487571780,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":58,"time":1785331618804,"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":59,"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":60,"time":1785464689023,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":61,"time":1785487571781,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":62,"time":1785487571781,"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":"aed7df90-6bb5-4f47-b832-c4c6b96c6232"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} -{"type":"tool/call","seq":63,"time":1785487571781,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} -{"type":"tool/result","seq":64,"time":1785487571843,"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":"336b2769-2ad8-46cb-8d0c-85f5a9eee595"}},"sourceEventSeqs":[63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785487571843,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":66,"time":1785487571843,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":67,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":68,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":69,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} -{"type":"assistant/chunk","seq":70,"time":1785464689075,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":71,"time":1785487571844,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":72,"time":1785487571844,"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":"c0a9b92f-bb85-4574-ac3c-9e551c113d9a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[67,68,69,70,71],"surfaceOp":"append"} -{"type":"step/end","seq":73,"time":1785487571844,"data":{"turn":1,"step":7}} -{"type":"turn/end","seq":74,"time":1785487571844,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"user/message","seq":5,"time":1785730508088,"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":"bb38bdc2-276e-46ec-87a1-089732acbc8d"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730508088,"data":{"title":"Prove that bash state persists.","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498592370,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730508089,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1785331618326,"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":11,"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":12,"time":1785498592372,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":1785730508090,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1785730508090,"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":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1785730508090,"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":16,"time":1785730508425,"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":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730508425,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1785730508425,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1785331618652,"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":21,"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":22,"time":1785498592702,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":1785730508426,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1785730508426,"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":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1785730508426,"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":26,"time":1785730508537,"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":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730508537,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1785730508537,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1785331618762,"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":31,"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":32,"time":1785498592812,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":33,"time":1785730508538,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1785730508538,"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":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1785730508538,"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":36,"time":1785730508551,"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":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1785730508551,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1785730508552,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":40,"time":1785331618784,"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":41,"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":42,"time":1785498592825,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":43,"time":1785730508552,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1785730508552,"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":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1785730508553,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} +{"type":"tool/result","seq":46,"time":1785730508554,"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":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1785730508554,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1785730508554,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":50,"time":1785331618801,"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":51,"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":52,"time":1785498592826,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":53,"time":1785730508555,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":54,"time":1785730508555,"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":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"tool/call","seq":55,"time":1785730508555,"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":56,"time":1785730508564,"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":[55],"surfaceOp":"append"} +{"type":"step/end","seq":57,"time":1785730508564,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":58,"time":1785730508565,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":60,"time":1785331618804,"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":61,"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":62,"time":1785498592838,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":63,"time":1785730508565,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1785730508565,"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":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730508565,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} +{"type":"tool/result","seq":66,"time":1785730508641,"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":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730508641,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":68,"time":1785730508642,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":69,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":70,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":71,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":72,"time":1785498592934,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":73,"time":1785730508642,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":74,"time":1785730508642,"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":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"step/end","seq":75,"time":1785730508642,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":76,"time":1785730508642,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl index df1f453b80..434027310a 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -1,178 +1,185 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"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":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}}}} -{"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":" the"}}}}} -{"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":" sub"}}}}} -{"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":"agent"}}}}} -{"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":" tool"}}}}} -{"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":" exactly"}}}}} -{"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":" once"}}}}} -{"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":" with"}}}}} -{"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":" description"}}}}} -{"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":"reasoning-delta","index":0,"text":"echo"}}}}} -{"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":" probe"}}}}} -{"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":"reasoning-delta","index":0,"text":" and"}}}}} -{"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":" prompt"}}}}} -{"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":" '"}}}}} -{"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":"Reply"}}}}} -{"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":" with"}}}}} -{"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":" exactly"}}}}} -{"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":":"}}}}} -{"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":" child"}}}}} -{"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":" answer"}}}}} -{"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":" "}}}}} -{"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":"42"}}}}} -{"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":".'\n"}}}}} -{"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":"2"}}}}} -{"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":"."}}}}} -{"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":" Then"}}}}} -{"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":" reply"}}}}} -{"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":" with"}}}}} -{"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":" the"}}}}} -{"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":" sub"}}}}} -{"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":"agent"}}}}} -{"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":"'s"}}}}} -{"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":" final"}}}}} -{"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":" answer"}}}}} -{"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":" verb"}}}}} -{"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":"atim"}}}}} -{"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":".\n\n"}}}}} -{"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":"Let"}}}}} -{"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":" me"}}}}} -{"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":" do"}}}}} -{"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":" this"}}}}} -{"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":" step"}}}}} -{"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":" by"}}}}} +{"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":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"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":63,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"{"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} -{"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":"description"}}}}} +{"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":": "}}}}} +{"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":"echo"}}}}} -{"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":" probe"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":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":"prom"}}}}} -{"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":"pt"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":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":"Reply"}}}}} -{"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":" with"}}}}} -{"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":" exactly"}}}}} -{"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":":"}}}}} -{"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":" child"}}}}} -{"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":" answer"}}}}} -{"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":" "}}}}} -{"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":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"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":"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":92,"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":93,"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":94,"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":95,"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":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":96,"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":"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":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"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":"request/header","seq":5,"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":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"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":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":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":"child"}}}}} -{"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":" answer"}}}}} -{"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":"42"}}}}} -{"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":".\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"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":23,"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":24,"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":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} -{"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":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"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":29,"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":30,"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":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":32,"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":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":34,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"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":"session/title","seq":6,"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":7,"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":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":19,"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":20,"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":21,"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":22,"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":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"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":" answer"}}}}} +{"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":" "}}}}} +{"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":"42"}}}}} +{"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":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"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":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"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":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.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":97,"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":[96],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":98,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":99,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":100,"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":101,"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":102,"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":103,"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":104,"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":105,"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":106,"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":107,"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":108,"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":109,"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":110,"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":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":" Now"}}}}} -{"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":" I"}}}}} -{"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":" need"}}}}} -{"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":" to"}}}}} -{"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":" reply"}}}}} -{"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":" with"}}}}} -{"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":" the"}}}}} -{"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":" sub"}}}}} -{"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":"agent"}}}}} -{"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":"'s"}}}}} -{"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":" final"}}}}} -{"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":" answer"}}}}} -{"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":" verb"}}}}} -{"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":"atim"}}}}} -{"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":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":127,"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":128,"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":129,"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":130,"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":131,"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":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":"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":134,"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":135,"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":136,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":137,"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":[100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":138,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":139,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} +{"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/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json index b83a869dee..ac27e85776 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json @@ -1 +1 @@ -{"status":"ok","reason":{"kind":"completed"},"finalResponse":"child answer 42."} +{"sessionId":"{{sessionId}}","finalResponse":"child answer 42."} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 52bf58335d..9c45784001 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,19 +1,21 @@ {"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{cwd}}","parentSession":"sdk-snapshot-subagent","origin":"subagent","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"b33e0611-0483-44a9-8b57-9828b87cb846"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1785534755048,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} -{"type":"step/start","seq":4,"time":1785534755048,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534755048,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785534755048,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":8,"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":22,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":23,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":28,"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":29,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":30,"time":1785460688799,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":31,"time":1785534755056,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1785534755056,"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":"2349c266-bebd-43c6-b434-c4c60809adfc"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785534755056,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1785534755056,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"session/title","seq":6,"time":1785730507335,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"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":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":25,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":30,"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":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":32,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":33,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"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":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785730507344,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index 4547e18d6f..dc9c8febc2 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,31 +1,33 @@ {"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"b898b99d-1200-4afc-ab38-03e243d79c42"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"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":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785460688754,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":6,"time":1785097409496,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":7,"time0":1785097409666,"data":{"turn":1,"step":1,"index":0,"dt":[25,1,0,0,0,0,24,0,1,0,0,0,26,0,0,0,0,0,26,0,0,0,0,0,30,0,0,1,0,0,20,1,0,0,28,0,1,0,0,0,23,1,0,0,0,0,25,1,25,26,1,0,0,79],"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":62,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":63,"time0":1785097410056,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0,26,0,0,0,51,1,0,0,0,0,26,1,0,0,0,25,1,0,0,25,1,0,57],"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","args":["","{","\"","description","\"",": ","\"","echo"," probe","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly",":"," child"," answer"," ","42",".","\"","}"]}} -{"type":"assistant/chunk","seq":91,"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":92,"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":93,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}} -{"type":"assistant/chunk","seq":94,"time":1785460688765,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":95,"time":1785460688765,"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":"5fad9b03-25df-46ab-aa60-a8082239cac0"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} -{"type":"tool/call","seq":96,"time":1785460688766,"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":97,"time":1785460688809,"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":"3a3d2c80-be55-425d-be1f-7cdb65aeebbd"}},"sourceEventSeqs":[96],"surfaceOp":"append"} -{"type":"step/end","seq":98,"time":1785460688809,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":99,"time":1785460688818,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":100,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":101,"time0":1785097411813,"data":{"turn":1,"step":2,"index":0,"dt":[26,0,26,1,26,0,0,0,0,26,0,1,0,0,25,0,0,28,0,0,1,0,0,23,1],"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":127,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":128,"time0":1785097411997,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,26,1],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":133,"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":134,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":135,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}} -{"type":"assistant/chunk","seq":136,"time":1785460688824,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":137,"time":1785460688824,"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":"7167b673-a371-4182-8497-ad2eb0194113"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136],"surfaceOp":"append"} -{"type":"step/end","seq":138,"time":1785460688824,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":139,"time":1785460688824,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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 index 8f3f86fb12..eb60d67a0c 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl @@ -1,39 +1,42 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"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":"request/header","seq":4,"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":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"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":7,"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":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":19,"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":20,"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":21,"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":22,"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":23,"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":24,"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":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":"text"}}}}} -{"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":"SD"}}}}} -{"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":"K"}}}}} -{"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":" snapshot"}}}}} -{"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":" OK"}}}}} -{"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 \"SDK snapshot OK\". Let me do that."}}}}}} -{"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":"SDK snapshot OK"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"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":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 \"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":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"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.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} +{"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/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json index 46cd4334e0..2d7ad76dcc 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json @@ -1 +1 @@ -{"status":"ok","reason":{"kind":"completed"},"finalResponse":"SDK snapshot OK"} +{"sessionId":"{{sessionId}}","finalResponse":"SDK snapshot OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index 68d90a507c..08b526a0ef 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -1,18 +1,20 @@ {"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"4e9cf703-273c-4181-a1e3-4da863efb02f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"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":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785460686781,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":6,"time":1785097381979,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":7,"time0":1785097382117,"data":{"turn":1,"step":1,"index":0,"dt":[28,27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":26,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":27,"time0":1785097382278,"data":{"turn":1,"step":1,"index":1,"dt":[0,1,0],"texts":["SD","K"," snapshot"," OK"]}} -{"type":"assistant/chunk","seq":31,"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":32,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} -{"type":"assistant/chunk","seq":33,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":34,"time":1785460686790,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785460686790,"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":"27e2d618-8cec-43a7-9d23-b5db95647662"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785460686791,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785460686791,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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 index def44e65e3..870762db51 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 -README.zh.md: 1249ae40bb344fc81836cb49d71dd5656457b1b3 +README.zh.md: ea27dc1a5bd644de13d4ecad8afcae3a7452160e diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 1249ae40bb..ea27dc1a5b 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -62,7 +62,7 @@ npm install --global @modelcontextprotocol/server-memory@2026.7.4 dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ``` -该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包(package)目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 +该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 搜索只对实体名称、类型和 observation 进行不区分大小写的子字符串匹配,不是语义检索。该服务器不提供 embedding、自动摘要、冲突消解或遗忘策略。 diff --git a/examples/package.json b/examples/package.json index 849d8fc3ff..3975d5573b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", + "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -42,8 +43,10 @@ "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repository-plugin": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:*", @@ -81,6 +84,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-pty": "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:*", diff --git a/examples/web-cordis/README.i18n.yaml b/examples/web-cordis/README.i18n.yaml new file mode 100644 index 0000000000..0564a4e0fc --- /dev/null +++ b/examples/web-cordis/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 examples/web-cordis/README.md +README.md: 75c7610fecc20777607c7c8cbb6ed2fc7f590b73 +README.zh.md: 5c25347ee17037bbfc7d10a5507424ce52e3faff diff --git a/examples/web-cordis/README.md b/examples/web-cordis/README.md new file mode 100644 index 0000000000..75c7610fec --- /dev/null +++ b/examples/web-cordis/README.md @@ -0,0 +1,21 @@ +# web-cordis + +English | [中文](README.zh.md) + +Self-referential demonstration of [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/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/cordis/tool-cordis/README.md) owns the tool, lifecycle, and safety contracts. diff --git a/examples/web-cordis/README.zh.md b/examples/web-cordis/README.zh.md new file mode 100644 index 0000000000..5c25347ee1 --- /dev/null +++ b/examples/web-cordis/README.zh.md @@ -0,0 +1,21 @@ +# web-cordis + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/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/cordis/tool-cordis/README.md)定义。 diff --git a/knip.json b/knip.json index fd941b03ca..dfb8058d7c 100644 --- a/knip.json +++ b/knip.json @@ -20,11 +20,13 @@ "workspaces": { ".": { "entry": [ - "scripts/**/*.mjs" + "scripts/**/*.mjs", + "scripts/**/*.cjs" ], "project": [ "scripts/**/*.ts", - "scripts/**/*.mjs" + "scripts/**/*.mjs", + "scripts/**/*.cjs" ], "ignoreDependencies": [ "playwright" @@ -41,6 +43,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -76,6 +79,16 @@ "tests/**/*.ts" ] }, + "packages/host/directory-picker-native": { + "entry": [ + "tests/**/*.spec.{ts,tsx}", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.{ts,tsx}" + ] + }, "packages/client/web-ui": { "entry": [ "tests/**/*.spec.{ts,tsx}" diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index b58b0fe1cc..a55273be29 100644 --- a/native/README.i18n.yaml +++ b/native/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 native/README.md -README.md: 84808b2ee9dafa4f9f980c35a81ebe12480a4f5d -README.zh.md: 276db0e655f2d632da9729c787b613cd231b2d40 +README.md: a79d9ca5747d4c4fbfa50745b3eece07b96aea58 +README.zh.md: 708430c0a087e5a9da1dae6fd078cb477db65d9b diff --git a/native/README.md b/native/README.md index 84808b2ee9..a79d9ca574 100644 --- a/native/README.md +++ b/native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family. +Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher consumed by the harness. The [`landlock-run/` workspace](landlock-run/README.md) owns its architecture, package family, platform support, development workflow, and release procedure. The standalone repository is a release mirror. ## Release mirror @@ -10,13 +10,4 @@ Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then- |---|---|---|---| | `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | -The subtree is a self-contained pnpm workspace with its own `AGENTS.md`, docs, gates, and lockfile; it is NOT part of the harness workspace (`pnpm-workspace.yaml` does not include it), so harness installs, builds, and CI gates never touch it. The mirror's `.github/` stays out of the subtree — [.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml) (manual dispatch) runs the subtree's CI legs here, and a change to those legs is mirrored into the mirror's `ci.yml` at the next export. - -## Export procedure (cutting a release) - -1. Land the launcher change here through a normal harness PR; dispatch the `Landlock Run` workflow and get its legs green. -2. In the mirror checkout, replace everything except `.github/`: `git -C <mirror> rm -rq -- . ':!.github'`, then `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`, then `git -C <mirror> add -A` and commit. -3. In the mirror, follow its release checklist (`docs/release.md`): `pnpm release:commit <version>` → merge → tag `vX.Y.Z` → two-phase `Release` workflow (`publish=false` rehearsal, then `publish=true` from the tag). -4. Update the manifest table above with the released tag/commit, and bump the harness consumers' dependency range in the same change. - -The mirror must not diverge: a change committed there directly (hotfix during a release) is ported back here before the next export. +The subtree is a self-contained pnpm workspace and is not part of the harness workspace. The [launcher release reference](landlock-run/docs/release.md) owns the export and publication workflow. The mirror must not diverge: port any direct mirror hotfix back here before the next export. diff --git a/native/README.zh.md b/native/README.zh.md index 276db0e655..708430c0a0 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,21 +2,12 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的权威源码位于此处:这是 harness 从 npm 引入并使用的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包(package)系列的发布镜像。 +`node-addon-landlock-run` 的真源;它是供 harness 使用、先施加 Landlock 自限再执行命令的启动器。[`landlock-run/` workspace](landlock-run/README.md)负责其架构、包家族、平台支持、开发工作流和发布流程。独立仓库是发布镜像。 ## 发布镜像 -| 目录 | 镜像仓库 | 上次导出的发布版 | Commit | +| 目录 | 镜像仓库 | 最近导出的版本 | Commit | |---|---|---|---| | `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | -该子树是一个自包含的 pnpm workspace,拥有自己的 `AGENTS.md`、文档、门禁和锁文件;它不属于 harness workspace(`pnpm-workspace.yaml` 不包含它),因此 harness 的安装、构建和 CI 门禁绝不会触及它。镜像的 `.github/` 不进入该子树;[.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml)(手动触发)在此处运行子树的 CI 任务,对这些任务的更改会在下次导出时镜像到镜像仓库的 `ci.yml`。 - -## 导出流程(发布新版本) - -1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。 -2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C <mirror> rm -rq -- . ':!.github'`,然后执行 `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`,最后执行 `git -C <mirror> add -A` 并提交。 -3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit <version>` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。 -4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中上调 harness 消费方的依赖版本范围。 - -发布镜像不得与此处的权威源码产生分歧:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 +该子树是自包含的 pnpm workspace,不属于 harness workspace。[启动器发布参考](landlock-run/docs/release.md)负责导出和发布工作流。镜像不得发生分歧:下次导出前,必须把任何直接施加于镜像的热修复移植回此处。 diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index e732ebdf31..bdcf985216 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/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 native/landlock-run/README.md -README.md: 284d5df764cf5a5205973696211aee2366d3b76e -README.zh.md: f369799cc8dcfb6de7c4b7b8c18857693d310418 +README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b +README.zh.md: 5d3c1c2cd692bb87d5a759a0a6f9628a3f065863 diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 284d5df764..19cc18830b 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -39,7 +39,7 @@ The public API is intentionally small: - `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal). - `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`. - `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied. -- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125): contract constants. +- `LAUNCHER_BIN` and `LAUNCHER_FAILURE_EXIT` (125): contract constants. A successfully exec'd child may also return 125, so consumers need the fatal diagnostic as well as the status to attribute launcher failure. The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md). diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index f369799cc8..5d3c1c2cd6 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包(package)以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。 +一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。 第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 @@ -39,7 +39,7 @@ if (probe(launcher) !== 'unusable') { - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125):契约常量。 +- `LAUNCHER_BIN` 和 `LAUNCHER_FAILURE_EXIT`(125):契约常量。成功完成 exec 的子进程也可能返回 125,因此消费者必须同时看到致命诊断和该状态,才能将结果归因为 launcher 失败。 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 diff --git a/native/landlock-run/docs/cli-contract.md b/native/landlock-run/docs/cli-contract.md index 57ab0f604c..ad9a002d8f 100644 --- a/native/landlock-run/docs/cli-contract.md +++ b/native/landlock-run/docs/cli-contract.md @@ -1,6 +1,6 @@ # CLI contract: landlock-run -This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs`); changing anything below requires a version bump for the whole package family and a note in the release notes. +This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it through the entry package (`launcherPath`/`probe`/`grantArgs`) and the launcher protocol; changing anything below requires a version bump for the whole package family and a note in the release notes. ## Invocation grammar @@ -19,8 +19,8 @@ landlock-run --probe ## Exit codes -- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run (fail-closed; the one exception is `exec` itself failing after restriction, which by definition never ran the command either). -- Any other status: the wrapped command's own exit status, passed through unchanged. +- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run. +- After a successful `exec`, every child status is passed through unchanged, including 125. Consumers therefore require both status 125 and a `landlock-run: ` fatal line to attribute launcher failure. - `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise. ## Report lines diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml index 86fe43ff4d..47d33e0d70 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/landlock-run/packages/entry/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/landlock-run/packages/entry/README.md README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 -README.zh.md: 6f8136c33560515af891b8873d007eb3e9b013e0 +README.zh.md: e4fcd33a256b51c815cdd1c6771be328bc46f138 diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index 6f8136c335..e4fcd33a25 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包(package)定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 +用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 53de86122f..7a4349a5ca 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -23,9 +23,10 @@ export const LAUNCHER_BIN = 'landlock-run' /** * The exit code for every launcher-level failure (usage error, unenforcing - * kernel, unopenable grant root, failed exec) — chosen because the wrapped - * command itself is unlikely to use it, so a consumer can tell launcher - * failures from command failures. Part of the CLI contract. + * kernel, unopenable grant root, failed exec). After a successful `exec`, the + * wrapped command may also return 125, so consumers also require a matching + * launcher-owned fatal diagnostic to attribute launcher failure. Part of the + * CLI contract. */ export const LAUNCHER_FAILURE_EXIT = 125 diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml index 76d4b9884a..f7e057193c 100644 --- a/native/landlock-run/packages/linux-arm64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-arm64/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/landlock-run/packages/linux-arm64/README.md README.md: e5117988cf0bae2227edaa041700c2f75753899c -README.zh.md: abbd0d1040638ad4d64f3ab219bedcd845eb5a9b +README.zh.md: e502b0239b5ed862af579b21e36b8c47d7d6107e diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index abbd0d1040..e502b0239b 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml index e24a9393c7..7050c110ef 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-x64/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/landlock-run/packages/linux-x64/README.md README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 -README.zh.md: e813bcef7143b46a756e5716234f3bc3850de712 +README.zh.md: 3b9578a7eb78dfc05977795ca521cf3a881e9f1a diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index e813bcef71..3b9578a7eb 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 4ab0070e1c..55385d2156 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -24,6 +24,8 @@ import { probe, } from 'node-addon-landlock-run'; +const FATAL_PREFIX = 'landlock-run: '; +const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; if (process.platform !== 'linux') { @@ -43,6 +45,7 @@ const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8' { const noCommand = run([]); assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT); + assert.ok(noCommand.stderr.startsWith(FATAL_PREFIX)); assert.match(noCommand.stderr, /usage error: missing `-- <argv>\.\.\.` command/); const unknownFlag = run(['--bogus', '--', 'true']); @@ -75,6 +78,7 @@ if (enforcement === 'unusable') { console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock'); process.exit(0); } +const expectedNotice = enforcement === 'partial' ? `${PARTIAL_NOTICE}\n` : ''; { const probeRun = run(['--probe']); assert.equal(probeRun.status, 0); @@ -86,9 +90,14 @@ if (enforcement === 'unusable') { const echo = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo confined-ok']); assert.equal(echo.status, 0, echo.stderr); assert.equal(echo.stdout, 'confined-ok\n'); + assert.equal(echo.stderr, expectedNotice); const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']); assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged'); + + const child125 = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `exit ${LAUNCHER_FAILURE_EXIT}`]); + assert.equal(child125.status, LAUNCHER_FAILURE_EXIT, 'a wrapped child may itself return the launcher failure status'); + assert.equal(child125.stderr, expectedNotice); } // --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec --- @@ -120,6 +129,7 @@ if (enforcement === 'unusable') { const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`); const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]); assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT); + assert.ok(badGrant.stderr.startsWith(FATAL_PREFIX)); assert.match(badGrant.stderr, /cannot open rule path/); assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails'); } diff --git a/package.json b/package.json index fef0a1eb53..7bd84db93a 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && 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-runtime-closure && pnpm run verify-vendored-links", + "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", @@ -133,6 +134,7 @@ "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", "fast-check": "^4.8.0", + "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", "jsdom": "29.1.1", diff --git a/packages/acp/README.i18n.yaml b/packages/acp/README.i18n.yaml index 08051fea69..ca4ec66f67 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: 326615210e5cfc39004fc5ab7462623089ac4126 -README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa +README.md: 3ba247598f29f2244456061fe7f9a3282086148f +README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a diff --git a/packages/acp/README.md b/packages/acp/README.md index 326615210e..3ba247598f 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -6,6 +6,6 @@ The ACP group exposes harness agents to programmatic clients. It is an interoper | Package | Role | |---|---| -| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. | +| [`acp/`](acp/README.md) | Automation-only ACP server. | The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract. diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md index 8679f2428a..c13bc05b12 100644 --- a/packages/acp/README.zh.md +++ b/packages/acp/README.zh.md @@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开 | 包 | 职责 | |---|---| -| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 | +| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 | 与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 898a23ffd2..e0353e1ff7 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: 583025e94d72c1ab03d282f8f4eb101c4e6f4740 -README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c +README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893 +README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 583025e94d..9cc4a5e271 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -4,7 +4,7 @@ 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 prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). -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 and TUI modules. +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. ## Plugin @@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target | `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). 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` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. | +| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | | `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. | | `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. 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. | @@ -37,6 +37,8 @@ Committed-message output intentionally trades token-by-token latency for a clean Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, 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. +ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately. + ## Running `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. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 3a082b423c..82aa5df2c7 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -4,7 +4,7 @@ 通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 -此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。 +此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。 ## 插件 @@ -24,7 +24,7 @@ | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | | `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 | +| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 | | `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | | `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | @@ -37,6 +37,8 @@ 客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 +ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。 + ## 运行 `pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。 @@ -47,7 +49,7 @@ #### 模型看到的内容 -`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 +`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。 #### Token 影响 @@ -65,11 +67,11 @@ #### Token 影响 -只有该工具的结果会贡献 token。 +只有所属工具的结果会贡献 token。 #### KV Cache 影响 -随该工具的结果仅追加。 +仅通过所属工具的结果追加。 ## 已知限制与暂缓事项 diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 6ef2f4def5..7aa9f8c4ef 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 8d4693d9d5..9fcdb68f7b 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -17,13 +17,17 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'end_turn' case 'max-tokens': return 'max_tokens' + // `cancelled` is reserved for explicit client cancellation (`session/cancel`) + // and disposal, both settled out of band; a turn aborted by a hook or + // another owner is ordinary quiescence and reports `end_turn`. case 'aborted': - case 'disposed': + return 'end_turn' case 'interrupted': return 'cancelled' + case 'blocked': case 'error': return 'end_turn' - // TurnEndReason is merge-extensible; future variants still need a legal wire value. + /* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */ default: return 'end_turn' } diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index b88322012c..a794c52901 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -90,13 +90,10 @@ interface SessionRecord { inflight: { resolve: (reason: StopReason) => void reject: (error: Error) => void + messageId: string turn: number | undefined - /** - * A failed turn's terminal reason, held until quiescence: a retry action - * closes the failed turn and opens a successor that adopts the prompt, so - * rejecting at `turn/end` would race the recovery. - */ - pendingError: Extract<TurnEndReason, { kind: 'error' }> | undefined + /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ + endReason: TurnEndReason | undefined } | undefined } @@ -149,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight: NonNullable<SessionRecord['inflight']>, reason: Extract<TurnEndReason, { kind: 'error' }>, ): void => { - inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) + inflight.reject(internalError(`turn failed: ${reason.error.message}`)) } // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, @@ -174,30 +171,33 @@ export function apply(ctx: Context, config: AcpConfig): void { } } finally { const inflight = record.inflight - if (inflight !== undefined && event.type === 'turn/start') { - if (inflight.turn === undefined && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'user') { - inflight.turn = event.data.turn - } else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') { - // A recovery policy opened a retry turn on the failed history: the - // prompt rides it instead of rejecting on the failed turn's end. - inflight.turn = event.data.turn - inflight.pendingError = undefined - } - } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { if (event.data.reason.kind === 'error') { - // Hold the rejection: request recovery may adopt the prompt with a - // successor turn; quiescence without one delivers this error. - inflight.turn = undefined - inflight.pendingError = event.data.reason - } else { + // Model failures surface immediately as prompt errors; ordinary + // endings wait for whole-agent idle below. record.inflight = undefined - inflight.resolve(turnEndToStopReason(event.data.reason)) + rejectFromError(inflight, event.data.reason) + } else { + inflight.endReason = event.data.reason } } } }) + 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 + }) + + ctx.on('agent/error', (agent, turn, _step, error) => { + const record = ownedRecord(agent) + const inflight = record?.inflight + if (record === undefined || inflight === undefined || inflight.turn === turn) return + record.inflight = undefined + inflight.reject(internalError(`turn failed: ${errorChain(error)}`)) + }) + // Permission requests are a machine policy channel for ACP clients such as // dsh-subagent-acp. The bridge offers one-shot choices only and never infers a // durable grant from an unknown client response. @@ -278,17 +278,18 @@ export function apply(ctx: Context, config: AcpConfig): void { 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: [{ type: 'text', text }], source: { kind: 'user' } }) const stopReason = await new Promise<StopReason>((resolve, reject) => { // Arm the slot before followup() so a listener-driven synchronous // turn cannot slip past correlation; a synchronous followup() // failure (invalid input) must free the slot again or the session // would reject every later prompt as already in flight. const inflight: NonNullable<SessionRecord['inflight']> = { - resolve, reject, turn: undefined, pendingError: undefined, + resolve, reject, messageId: message.id, turn: undefined, endReason: undefined, } record.inflight = inflight try { - record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) + record.agent.followup(message) // The machine's send() contains listener failures and accepts // any typed input; this guards a future synchronous throw so the // slot cannot wedge. @@ -299,18 +300,21 @@ export function apply(ctx: Context, config: AcpConfig): void { throw internalError(`prompt was not queued: ${detail}`) } /* v8 ignore stop */ - // Admission is pre-turn and retries outlive their failed turn, so a - // turnless slot settles only at quiescence: a held failure rejects - // (no retry adopted the prompt); no turn at all means admission - // discarded the prompt — report cancelled. + // Settlement waits for whole-agent idle: a correlated turn/end arms + // `endReason`, while a turnless slot (admission discarded the + // prompt) stays cancelled. Other producers may run further turns + // before quiescence; the prompt settles only when the agent stops. void record.agent.whenIdle().then(() => { - if (record.inflight !== inflight || inflight.turn !== undefined) return + if (record.inflight !== inflight) return record.inflight = undefined - if (inflight.pendingError !== undefined) { - rejectFromError(inflight, inflight.pendingError) - return + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else { + // Token-limit and other non-terminal endings are not prompt-level + // stop reasons (see README); only normal quiescence reports end_turn. + inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) } - inflight.resolve('cancelled') }) }) return { stopReason } diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 01bcd83249..ea1ec994a4 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -20,7 +20,7 @@ describe('ACP machine permission policy', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) 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, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.session.append('turn/start', { turn: 1 }) return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides } } diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 7f5441e4df..335ead9798 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -1,37 +1,24 @@ import { describe, expect, it } from 'vitest' import type { TurnEndReason } from '@deepseek-ai/dsh-session' -import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from '../src/codec.ts' +import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts' -describe('ACP automation codec', () => { - it('maps every known turn outcome to a legal stop reason', () => { - const cases: [TurnEndReason, string][] = [ - [{ kind: 'completed' }, 'end_turn'], - [{ kind: 'max-tokens' }, 'max_tokens'], - [{ kind: 'aborted' }, 'cancelled'], - [{ kind: 'disposed' }, 'cancelled'], - [{ kind: 'interrupted' }, 'cancelled'], - [{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'], - ] - for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected) +describe('ACP codec', () => { + it.each([ + [{ kind: 'completed' }, 'end_turn'], + [{ kind: 'max-tokens' }, 'max_tokens'], + [{ kind: 'aborted', reason: { kind: 'user' } }, 'end_turn'], + [{ kind: 'interrupted' }, 'cancelled'], + [{ kind: 'blocked' }, 'end_turn'], + [{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'end_turn'], + ] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => { + expect(turnEndToStopReason(reason)).toBe(expected) }) - it('uses a legal fallback for merge-extensible future outcomes', () => { - expect(turnEndToStopReason({ kind: 'future' } as unknown as TurnEndReason)).toBe('end_turn') - }) - - it('flattens baseline blocks and rejects everything richer', () => { - expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab') - expect(acpPromptToText([ - { type: 'text', text: 'see' }, - { type: 'resource_link', name: 'x', uri: 'file:///x' }, - ])).toBe('see\n[resource_link name="x" uri="file:///x"]\n') - expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('') - expect(promptHasUnsupportedContent([ - { type: 'text', text: 'ok' }, - { type: 'resource_link', name: 'x', uri: 'file:///x' }, - ])).toBe(false) - expect(promptHasUnsupportedContent([ - { type: 'image', data: '', mimeType: 'image/png' }, - ])).toBe(true) + it('drops unsupported blocks from baseline text conversion', () => { + expect(acpPromptToText([{ + type: 'image', + data: '', + mimeType: 'image/png', + }])).toBe('') }) }) diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index b4b1eaeef2..48b5e76095 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -67,7 +67,15 @@ describe('ACP connection ownership', () => { const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await vi.waitFor(() => { expect(agent.status).toBe('running') }) - harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') }) + const cancel = agent.cancel.bind(agent) + let cancelObserved = false + vi.spyOn(agent, 'cancel').mockImplementation((...args) => { + if (!cancelObserved) { + cancelObserved = true + order.push('parent cancelled') + } + cancel(...args) + }) const disposal = harness.acpFiber.dispose() // A drain can block on persistence, so the bridge's own turn must already be diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 70ebfae020..329aff6d96 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -31,11 +31,13 @@ describe('ACP prompt lifecycle', () => { harness = undefined }) - it('maps a max-token turn without losing its committed text', async () => { + it('maps a max-token turn to end_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' }] }) - expect(result.stopReason).toBe('max_tokens') + // 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') await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) @@ -49,12 +51,27 @@ describe('ACP prompt lifecycle', () => { it('rejects an ordinary plugin failure through the same prompt boundary', async () => { harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) - harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') }) + harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .rejects.toThrow(/turn failed: plugin pre-step failed/) }) + it('rejects a turn-start failure before the prompt is claimed', async () => { + harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + const append = agent.session.append.bind(agent.session) + vi.spyOn(agent.session, 'append').mockImplementation(((type: string, ...rest: never[]) => { + if (type === 'turn/start') throw new Error('turn start unavailable') + return (append as (...args: never[]) => unknown)(type as never, ...rest) + }) as never) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: turn start unavailable/) + vi.restoreAllMocks() + }) + it('settles even when an earlier turn observer throws', async () => { harness = await makeBridgeHarness({ script: [textResponse('answer')] }) harness.ctx.on('session/event', (_session, event) => { @@ -65,13 +82,13 @@ describe('ACP prompt lifecycle', () => { .resolves.toEqual({ stopReason: 'end_turn' }) }) - it('ignores an injection turn while correlating the owning message turn', async () => { + it('correlates the owning prompt when a synchronous injection joins its first step', async () => { harness = await makeBridgeHarness({ script: [textResponse('real answer')] }) const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! let injected = false - harness.ctx.on('agent/inbox/enqueue', (subject) => { - if (subject === agent && !injected) { + harness.ctx.on('agent/inbox/inserted', (subject, { message }) => { + if (subject === agent && message.source.kind === 'user' && !injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })) } @@ -86,30 +103,44 @@ describe('ACP prompt lifecycle', () => { harness = await makeBridgeHarness({ script: ['hang'] }) const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! - let inserted = false - harness.ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject !== agent || item.message.source.kind !== 'user' || inserted) return - inserted = true - const source = { kind: 'plugin', plugin: 'test' } as const - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'autonomous work' }], - source, - }), { surfaceOp: 'append' }) - agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + let autonomousStarted!: () => void + const started = new Promise<void>((resolve) => { autonomousStarted = resolve }) + harness.ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/chunk') autonomousStarted() }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'autonomous work' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + await started let settled = false const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.length > 0)).toHaveLength(2) }) expect(settled).toBe(false) await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) }) + it('correlates a prompt whose step history is replaced', async () => { + harness = await makeBridgeHarness({ script: [textResponse('rewritten answer')] }) + harness.ctx.on('agent/pre-step', async () => ({ + kind: 'enter', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'rewritten prompt' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + const sessionId = await newSession(harness) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'original' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + }) + it('frees the prompt slot when the agent rejects the send synchronously', async () => { harness = await makeBridgeHarness({ script: [] }) const sessionId = await newSession(harness) @@ -143,7 +174,39 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason).toEqual({ kind: 'aborted' }) + expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) + }) + + it('settles a hook-cancelled turn as end_turn, not cancelled', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const sessionId = await newSession(harness) + const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + await vi.waitFor(() => { expect(agent.status).toBe('running') }) + // A hook or another owner cancels the agent: the ACP client never called + // session/cancel, so this is ordinary quiescence and reports end_turn. + agent.cancel({ kind: 'hook', reason: 'owner intervention' }) + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it('cancels autonomous running work without an in-flight prompt', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'autonomous work' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + await vi.waitFor(() => { + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + }) + + await harness.client.cancel({ sessionId }) + await agent.whenIdle() + + expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) it('an idle cancel does not affect the following prompt', async () => { @@ -184,7 +247,7 @@ describe('ACP prompt lifecycle', () => { await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') }) }) - it('a failed turn with no retry still rejects, at quiescence', async () => { + it('a failed turn with no retry still rejects', async () => { harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] }) let offered = 0 harness.ctx.on('agent/request-error', async () => { offered += 1 }) @@ -194,13 +257,36 @@ describe('ACP prompt lifecycle', () => { expect(offered).toBe(1) }) - it('an admission-blocked prompt settles cancelled instead of hanging', async () => { + it('a pre-step-rejected prompt settles instead of hanging', async () => { harness = await makeBridgeHarness({ script: [] }) - harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' })) + harness.ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, + })) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) - .resolves.toEqual({ stopReason: 'cancelled' }) - // The blocked prompt opened no turn and streamed nothing. + .resolves.toEqual({ stopReason: 'end_turn' }) + // The rejected prompt closed a blocked turn without streaming anything. expect(messageText(harness)).toBe('') }) + + it('cancels a prompt removed before its turn claims it', async () => { + harness = await makeBridgeHarness({ script: [] }) + const sessionId = await newSession(harness) + const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => { + if (message.source.kind === 'user') agent.inbox.remove(message.id) + }) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'cancelled' }) + dispose() + }) + + it('rejects a prompt when pre-step fails inside its open turn', async () => { + harness = await makeBridgeHarness({ script: [] }) + harness.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') }) + const sessionId = await newSession(harness) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: pre-step exploded/) + }) }) diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 0af14fda76..4c4a2c6180 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/README.md -README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b -README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea +README.md: 601782caad24d3555a206365a3f1954d81af1cf0 +README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c diff --git a/packages/bash/README.md b/packages/bash/README.md index e60ad9b0e4..601782caad 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -2,13 +2,16 @@ English | [中文](README.zh.md) -The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. +The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All are **product** packages. | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | -| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | -| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | -| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | +| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` | +| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) | +| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) | +| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) | +| [`bash-env/`](bash-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.bashEnv` | +| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-task integration to the model. | (registers on `ctx.tools`) | +| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) | -The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)). +A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring. diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index deb23ea820..8b96c4f80b 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -1,14 +1,17 @@ -# bash/:bash 能力家族 +# bash/ — bash 能力家族 [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 +该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | -| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | -| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | -| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | +| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` | +| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash`) | +| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash`) | +| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash`) | +| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.bashEnv` | +| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools`) | +| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) | -接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 +叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。 diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml new file mode 100644 index 0000000000..47f4cf01cc --- /dev/null +++ b/packages/bash/bash-env/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/bash/bash-env/README.md +README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f +README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78 diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md new file mode 100644 index 0000000000..7b939326d4 --- /dev/null +++ b/packages/bash/bash-env/README.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +English | [中文](README.zh.md) + +The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly. + +The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin. + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md new file mode 100644 index 0000000000..b6f3aca417 --- /dev/null +++ b/packages/bash/bash-env/README.zh.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +[English](README.md) | 中文 + +工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。 + +包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。 + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。 + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。 diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json new file mode 100644 index 0000000000..33243ab344 --- /dev/null +++ b/packages/bash/bash-env/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-bash-env", + "description": "Tool-independent managed DSH_* shell environment registry", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts new file mode 100644 index 0000000000..c7caa89f08 --- /dev/null +++ b/packages/bash/bash-env/src/index.ts @@ -0,0 +1,217 @@ +/** + * Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of + * trusted, per-execution `DSH_*` variables consumed by the model-facing shell + * tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by + * the registry itself while plugins can register additional, enumerable facts + * with effect-scoped disposal. + * + * @module @deepseek-ai/dsh-bash-env + */ + +import { Service, type Context } from 'cordis' +import z from 'schemastery' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-persistence' + +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + +export const name = 'bash-env' +export const inject: string[] = [] + +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} + +/** Runtime configuration schema for the bash-env plugin. */ +export const Config: z<Config> = z.object({ + dshHome: z.string(), +}) + +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model shell call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the shell tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model shell call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map<string, BashEnvContributor>() + private readonly keyOwners = new Map<DshEnvironmentKey, string>() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one shell tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record<DshEnvironmentKey, string> = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + +/** + * Load the bash-env plugin: register the `ctx.bashEnv` service and the + * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`). + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ +export function apply(ctx: Context, config: Config = {}): void { + const registry = new BashEnvRegistry(ctx, config) + registry.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) +} diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts new file mode 100644 index 0000000000..31f842c56d --- /dev/null +++ b/packages/bash/bash-env/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`. + * @module @deepseek-ai/dsh-bash-env/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' + +/** Cordis companion plugin name. */ +export const name = 'bash-env-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * registration/collection; it publishes no independent snapshot that a companion could cross-check. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts similarity index 79% rename from packages/bash/tool-bash/tests/bash-env.spec.ts rename to packages/bash/bash-env/tests/bash-env.spec.ts index d988075c5b..c93a768f80 100644 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -1,3 +1,9 @@ +/** + * Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor + * ownership and validation, collection ordering, effect-scoped disposal, and + * the explicit disposer contract. + */ + import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +11,8 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' +import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' const testToolSignal = new AbortController().signal @@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => { dispose() expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') }) + + it('the plugin registers the service and the persistence contributor on load', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry) + expect(ctx.bashEnv.list()).toEqual([ + { + contributor: 'session-persistence', + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + key: 'DSH_SESSION_JSONL', + }, + ]) + }) + + it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl') + }) + + it('the persistence contributor omits the variable for a non-jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) + + it('the persistence contributor omits the variable without a persistence backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) }) diff --git a/packages/bash/bash-env/tsconfig.json b/packages/bash/bash-env/tsconfig.json new file mode 100644 index 0000000000..bcf5eb5229 --- /dev/null +++ b/packages/bash/bash-env/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index e72432de87..4d0214386f 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md -README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 -README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17 +README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67 +README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 694b7a7686..bb87ad6fe0 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -20,15 +20,13 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` -## Behavior (and where it came from) +## Behavior -Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). -- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with. ## Model Experience diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index c56543f269..d2f8c90910 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -20,15 +20,13 @@ graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` -## 行为(以及设计来源) +## 行为 -设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: - -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。 -- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 -- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 -- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。 ## 模型体验 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index bacc7ac92d..c3c3a5c3e6 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..4ed49be02a 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,10 +1,11 @@ /** * Local implementation of the bash executor seam over the subprocess - * seam. Each command runs as `bash -c` in a managed process group spawned - * through `ctx.subprocess`; this executor owns command defaulting, deadlines - * and cause classification, the model-friendly terminal environment, and the - * model-facing stdout/stderr merge for background reads. Execution policy - * belongs in `tools/pre-execute` or a sandboxing executor. + * seam. Public commands run as `bash -c` in a managed process group spawned + * through `ctx.subprocess`; subclasses may reuse the same mechanics with an + * explicit argv. This executor owns command defaulting, deadlines and cause + * classification, the model-friendly terminal environment, and the model-facing + * stdout/stderr merge for background reads. Execution policy belongs in + * `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ @@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + /** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + private spawnSpec( + spec: BashExecSpec, + argv: readonly string[], + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: ['bash', '-c', spec.command], + argv, cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise<BashRunResult> { + return this.runArgv(spec, ['bash', '-c', spec.command]) + } + + /** + * Run an explicit argv with the foreground lifecycle, environment, output, + * timeout, and cancellation semantics of this executor. Subclasses use this + * after replacing the public command's shell argv at an execution boundary. + * @param spec - resolved execution settings and caller-owned command metadata. + * @param argv - exact executable and arguments to hand to `ctx.subprocess`. + * @returns the settled foreground result with collected output and cause facts. + */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal)) const outcome = await handle.done const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, ['bash', '-c', spec.command]) + } + + /** + * Start an explicit argv with the background lifecycle, environment, output, + * cancellation, and process-tree ownership semantics of this executor. + * Subclasses use this after replacing the public command's shell argv at an + * execution boundary. + * @param spec - resolved execution settings and caller-owned command metadata. + * @param argv - exact executable and arguments to hand to `ctx.subprocess`. + * @returns the live background handle; spawn rejection settles it as killed. + */ + protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -216,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, collected.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -261,8 +292,10 @@ export class LocalBashExecutor extends BashExecutor { * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnFailed - whether the subprocess promise rejected before a process started. + * @param _spawnError - the original spawn rejection reason, which may itself be undefined. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } export default LocalBashExecutor diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index 640eb1810f..2de1c468b8 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md -README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7 -README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb +README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7 +README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 035a8ad240..74c1e28f76 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. -The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. +The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal. -Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. +Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. | Mode | File effects | |---|---| @@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). -- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. +- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path. - **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -36,8 +36,6 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego name: '@deepseek-ai/dsh-bash-sandbox' ``` -The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. - ## Model Experience ### Bash tool schema, indirectly @@ -72,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. +If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix. #### Token effect @@ -86,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox. - **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed. -- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`. +- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately. - **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile. diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index cee27a9baa..2593049ce0 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -4,9 +4,9 @@ 这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 -包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 +包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。 -每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 | 模式 | 文件影响 | |---|---| @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 - **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 @@ -36,8 +36,6 @@ name: '@deepseek-ai/dsh-bash-sandbox' ``` -无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 - ## 模型体验 ### 间接的 Bash 工具 schema @@ -72,7 +70,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。 #### Token 影响 @@ -86,5 +84,5 @@ - **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 - **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 -- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 +- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 0f2240630c..e3d3c3deb3 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index a98f47216e..357c73efa1 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -1,18 +1,61 @@ /** - * Internal shell-quoting and sandbox-result classification helpers. + * Internal sandbox-result classification helpers. * * @module @deepseek-ai/dsh-bash-sandbox/helpers */ +import { accessSync, constants, statSync } from 'node:fs' import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} /** - * Quote one string as a single-quoted POSIX shell word. - * @param text - raw argv element to preserve through the outer shell parse. - * @returns the quoted shell word. + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. */ -export function shellQuote(text: string): string { - return `'${text.replaceAll("'", String.raw`'\''`)}'` +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string } /** @@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin } /** - * Classify a failed run against the selected backend's runner-failure dialect. - * @param result - settled foreground run. - * @param signatures - case-insensitive runner-failure substrings from the active wrap. - * @returns whether the failed run matches that runner-failure dialect. + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. */ -export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean { - return matchesSignature(result.exitCode, result.stderr.text, signatures) +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined } /** diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index f46bad4006..eadbae9fef 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,21 +1,29 @@ /** * Sandbox-consuming bash executor. It wraps the exact local bash argv through * `ctx.sandbox`, inherits local process mechanics, and reports the selected - * mode, enforcement, and denial facts. Runner failure means the command never - * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background - * processes carry `runnerFailed`. The tool owns approval and passes a complete - * per-call policy. + * mode, enforcement, and denial facts. Positive runner-launch evidence means + * the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool owns approval and passes a complete per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ import { Context } from 'cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedArgv, + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' -import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — @@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { mode: ConfinedSandboxMode enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] + runnerProgram: string | undefined + workdir: string }>() constructor(ctx: Context, config: Config) { @@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor { return { ...result, sandbox: { mode, denied: false } } } const confined = this.confine(spec.command, { ...policy, mode }) - const result = await super.run({ ...spec, command: confined.command }) - // Runner failure outranks denial because the command did not run. Throw the - // same fail-closed error as confine-time discovery with the first stderr line. - if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) { - throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0]) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) } return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } } @@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor { const policy = spec.sandboxPolicy as SandboxExecutionPolicy const { mode } = policy if (mode === 'danger-full-access') return super.start(spec) - // Install facts synchronously; promise settlement cannot run before start() returns. + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. const confined = this.confine(spec.command, { ...policy, mode }) - const proc = super.start({ ...spec, command: confined.command }) - const { enforcement, denialSignatures, runnerFailureSignatures } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + // LocalSubprocessService reports provenanced ENOENT/EACCES through async + // `done` rejection; this covers alternatives that throw that shape synchronously. + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) return proc } @@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Stamp per-process sandbox facts before `done` settles. Full-access processes * have no facts; signal deaths are not denials. */ - protected override onProcessDone(proc: BashProcess, stderr: string): void { + protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // Runner failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), @@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr) + super.onProcessDone(proc, stderr, spawnFailed, spawnError) } /** - * Wrap one shell command via the `ctx.sandbox` provider: hand over the - * exact `['bash', '-c', command]` argv this executor would spawn, get back - * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns - * `exec`s into the runner, so no extra shell lingers). Provider errors - * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. + * Wrap one shell command via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param command - shell source for the confined inner `bash -c`. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. */ - private confine(command: string, policy: SandboxPolicy): { - command: string - enforcement: SandboxEnforcement - denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] - } { - const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) - return { - command: `exec ${confined.argv.map(shellQuote).join(' ')}`, - enforcement: confined.enforcement, - denialSignatures: confined.denialSignatures, - runnerFailureSignatures: confined.runnerFailureSignatures, - } + private confine(command: string, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(['bash', '-c', command], policy) } } diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts new file mode 100644 index 0000000000..23546e5d92 --- /dev/null +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -0,0 +1,270 @@ +/** + * Deterministic real-process proofs for runner classification: the real local + * provider and sandbox bash executor exercise direct runner-spawn failures + * and a POSIX fake Landlock launcher that prints its notice before exec. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const FATAL_PREFIX = 'landlock-run: ' +const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument` + +const contexts: Context[] = [] +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** Write a fake native launcher that reports partial enforcement, then execs or fails. */ +async function fakeLauncher(fatalExit?: number): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-')) + tempDirs.push(dir) + const launcher = join(dir, 'landlock-run') + const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n` + await writeFile(launcher, `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro|--rw) shift 2 ;; + --) shift; break ;; + *) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;; + esac +done +printf '%s\\n' '${NOTICE}' >&2 +${fatalBranch}exec "$@" +`, { mode: 0o755 }) + return launcher +} + +async function setup(fatalExit?: number): Promise<SandboxBashExecutor> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { + platform: 'linux', + probeBwrap: () => false, + probeLandlock: () => 'partial', + landlockLauncher: await fakeLauncher(fatalExit), + } + await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) + return ctx.bash as SandboxBashExecutor +} + +async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider, { + runnerCommand: [runner], + runnerFailureSignatures: ['configured-runner: fatal'], + }) + await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) + return ctx.bash as SandboxBashExecutor +} + +describe('partial Landlock runner-failure classification', () => { + it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-')) + tempDirs.push(dir) + const runner = join(dir, `${kind}-runner`) + if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 }) + if (kind === 'missing-interpreter') { + await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 }) + } + const bash = await setupConfiguredRunner(runner) + + const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(runner) + + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + expect(task.status).toBe('killed') + expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`) + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) + const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts + expect(accounting.size).toBe(0) + }) + + it.each(['bare-name', 'relative'] as const)( + 'classifies a %s runner whose shebang interpreter is missing', + async (form) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-')) + tempDirs.push(dir) + const filename = 'missing-interpreter-runner' + const runner = form === 'bare-name' ? filename : `./${filename}` + await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 }) + const bash = await setupConfiguredRunner(runner) + const request = form === 'bare-name' + ? { command: 'true', env: { PATH: dir } } + : { command: 'true', workdir: dir } + + const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + // Empirically, Darwin and Linux Node 24 preserve the passed bare/relative + // argv[0] in this spawn error rather than resolving it to an absolute path. + expect((error as Error).message).toContain(`spawn ${runner} ENOENT`) + + const task = bash.start(bash.resolve(request)) + await task.done + expect(task.status).toBe('killed') + expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`) + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) + }, + ) + + it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-')) + tempDirs.push(dir) + const runner = join(dir, 'malformed-runner') + await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 }) + const bash = await setupConfiguredRunner(runner) + const request = { command: 'true' } + + // Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a + // no-shebang executable through /bin/sh (Linux). Neither path supplies the + // provenanced ENOENT/EACCES evidence required for runner attribution. + const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value) + expect(foreground).not.toBeInstanceOf(SandboxUnavailableError) + + if (foreground instanceof Error) { + expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((foreground as { path?: unknown }).path).toBeUndefined() + + let background: unknown + try { + bash.start(bash.resolve(request)) + } catch (error) { + background = error + } + expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((background as { path?: unknown }).path).toBeUndefined() + expect(background).not.toBeInstanceOf(SandboxUnavailableError) + } else { + expect(foreground).toMatchObject({ + exitCode: 127, + signal: null, + sandbox: { mode: 'read-only', denied: false, enforcement: 'full' }, + }) + expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0) + + const background = bash.start(bash.resolve(request)) + await background.done + expect(background.status).toBe('completed') + expect(background.exitCode).toBe(127) + expect(background.signal).toBeNull() + expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + const output = background.readOutput().delta + expect(output.startsWith('[stderr]\n')).toBe(true) + expect(output.length).toBeGreaterThan('[stderr]\n'.length) + expect(output).not.toContain('spawn failed:') + } + + const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts + expect(accounting.size).toBe(0) + }) + + it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])( + 'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line', + async (exitCode) => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${NOTICE}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + }, + ) + + it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${NOTICE}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + }) + + it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => { + const bash = await setup(exitCode) + const result = await bash.run(bash.resolve({ command: 'true' })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + }) + + it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => { + const bash = await setup(LAUNCHER_FAILURE_EXIT) + const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`Runner failure: ${FATAL}`) + expect((error as Error).message).not.toContain(NOTICE) + }) + + it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + }) + + it('applies the same evidence rule to notice-only background exits', async () => { + const bash = await setup() + for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) { + const task = bash.start(bash.resolve({ command })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + } + }) + + it('classifies a background notice plus child Permission denied as denial', async () => { + const bash = await setup() + const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + }) + + it('makes a background fatal line outrank denial text after the notice', async () => { + const bash = await setup(LAUNCHER_FAILURE_EXIT) + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'partial', + runnerFailed: true, + }) + const output = task.readOutput().delta + expect(output).toContain(NOTICE) + expect(output).toContain(FATAL) + }) +}) diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 90a67999c8..5bcf68df2d 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -5,7 +5,7 @@ * the Unix denial signature used by the classifier without requiring a real sandbox runner. */ -import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) @@ -30,12 +31,19 @@ interface ConfineCall { /** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const -/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ -const RUNNER_FAILURE = ['fake-runner: '] as const +/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ +const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const + +/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */ +const RUNNER_FORMS = [ + ['absolute', process.execPath], + ['bare', 'node'], + ['relative', './sandbox-runner'], +] as const /** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ const passthrough = (argv: readonly string[]): ConfinedArgv => - ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }) + ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }) /** * Boot a context with a recording fake `ctx.sandbox` (behavior injectable @@ -90,15 +98,49 @@ describe('the provider hand-off', () => { }]) }) - it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => { - // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix: - // the sentinel only prints if the executor spawned the WRAPPED argv. - const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => { + const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"'] + const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' })) expect(result.stdout.text).toBe('1') + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) }) + it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-')) + const hook = join(dir, 'hook.sh') + const order = join(dir, 'order.txt') + writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n') + const runnerScript = [ + 'const { appendFileSync } = require("node:fs");', + 'const { spawnSync } = require("node:child_process");', + 'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");', + 'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });', + 'process.exit(child.status ?? 125);', + ].join('') + const { bash } = await setup({}, argv => ({ + argv: [process.execPath, '-e', runnerScript, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + + try { + const result = await bash.run(bash.resolve({ + command: 'true', + env: { BASH_ENV: hook }, + dshEnv: { DSH_ORDER_FILE: order }, + })) + expect(result.exitCode).toBe(0) + expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) @@ -120,9 +162,6 @@ describe('the provider hand-off', () => { expect(calls).toHaveLength(2) }) - it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => { - expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`) - }) }) describe('fail closed', () => { @@ -132,6 +171,120 @@ describe('fail closed', () => { await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) expect(() => bash.start(spec)).toThrow(SandboxUnavailableError) }) + + it('preserves an already-aborted foreground call as cancellation', async () => { + const { bash } = await setup() + const controller = new AbortController() + const reason = new Error('caller cancelled before spawn') + controller.abort(reason) + await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason) + }) + + it.each(RUNNER_FORMS)( + 'keeps an invalid workdir ordinary with the %s provider-runner form', + async (_form, runner) => { + const { bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }, + ) + + it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => { + const { bash } = await setup({ mode: 'danger-full-access' }) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => { + const runner = join(spillDir, 'malformed-runner') + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { + throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' }) + }) + + const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error) + expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect(foreground).not.toBeInstanceOf(SandboxUnavailableError) + + let background: unknown + try { + bash.start(bash.resolve({ command: 'true' })) + } catch (error) { + background = error + } + expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect(background).not.toBeInstanceOf(SandboxUnavailableError) + }) + + it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => { + const runner = join(spillDir, 'unexecutable-runner') + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + // This pins an alternative SubprocessService's synchronous seam, not the + // shipped local behavior. + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { + throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner }) + }) + + await expect(bash.run(bash.resolve({ command: 'true' }))) + .rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(() => bash.start(bash.resolve({ command: 'true' }))) + .toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })) + }) + + it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => { + const runner = './sandbox-runner' + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + const workdir = join(parent, 'missing') + const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner }) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure }) + try { + let thrown: unknown + try { + bash.start(bash.resolve({ command: 'true', workdir })) + } catch (error) { + thrown = error + } + expect(thrown).toBe(failure) + expect(thrown).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) }) describe('danger-full-access', () => { @@ -233,15 +386,134 @@ describe('classifyDenial', () => { }) }) +describe('isRunnerSpawnFailure', () => { + it.each(['EACCES', 'ENOENT'])( + 'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated', + (code) => { + const runner = join(spillDir, 'runner') + const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true) + }, + ) + + it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])( + 'keeps unproven executable code %s ordinary despite synthetic argv[0] fields', + (code) => { + const runner = join(spillDir, 'runner') + const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false) + }, + ) + + it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => { + const missingWorkdir = join(spillDir, 'missing-workdir') + for (const [, runner] of RUNNER_FORMS) { + const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false) + } + const fileWorkdir = join(spillDir, 'not-a-workdir') + writeFileSync(fileWorkdir, '') + const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' }) + expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false) + }) + + it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => { + const missingRunner = join(spillDir, 'definitely-missing-runner') + const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) => + Object.assign(new Error('spawn failed'), { code, syscall, path }) + const spawnErrorWithoutPath = (syscall: string) => + Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall }) + + expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false) + }) + + it('accepts only syscall provenance compatible with the exact runner program', () => { + const runner = join(spillDir, 'runner with spaces') + const spawnError = (syscall: string, path?: string) => + Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path }) + + expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false) + }) +}) + describe('classifyRunnerFailure', () => { - it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => { - const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'] - expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true) - expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true) + it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }] + expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined() + expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined() + }) + + it('keeps valid fatal signatures active beside an ignored empty entry', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const fatal = 'landlock-run: ruleset creation failed' + const rules = [{ + allowedExitCodes: [125], + fatalSignatures: ['', ' ', 'landlock-run: '], + informationalLines: [notice], + }] + expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal }) + }) + + it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }] + expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined() + expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules)) + .toEqual({ detail: `${notice}: extra detail` }) + expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules)) + .toEqual({ detail: 'landlock-run: exec failed: No such file or directory' }) + }) + + it.each([ + 'landlock-run: usage error: missing `-- <argv>...` command', + 'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)', + 'landlock-run: cannot open rule path: /gone: No such file or directory', + 'landlock-run: landlock ruleset error: Invalid argument', + 'landlock-run: exec failed: Permission denied', + 'landlock-run: out of memory', + 'landlock-run: future fatal diagnostic', + ])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => { + const rules = [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }] + expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal }) }) }) describe('result facts', () => { + it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => { + const { bash } = await setup({}, argv => ({ + argv: ['env', ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) + expect(result.exitCode).toBe(exitCode) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }) + it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => { const { bash } = await setup() const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked') @@ -253,25 +525,66 @@ describe('result facts', () => { }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { - const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) }) describe('background sandbox facts', () => { - it('stamps facts and releases accounting when background spawn fails', async () => { - const { bash } = await setup() - const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing') - const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir })) + it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => { + const { bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + await task.done + expect(task.status).toBe('killed') + expect(task.readOutput().delta).toContain('spawn failed:') + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + }) + const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts + expect(accounting.size).toBe(0) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { + const { ctx, bash } = await setup() + const emptyReader: SubprocessOutputReader = { + readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), + } + vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: emptyReader, stderr: emptyReader }, + // Arbitrary subprocess providers can reject without a value; that edge is the point of this test. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + done: Promise.reject(undefined), + terminate: vi.fn(), + waitForExit: async () => true, + } satisfies SubprocessHandle) + + const task = bash.start(bash.resolve({ command: 'true' })) await task.done - expect(task.status).toBe('killed') - expect(task.readOutput().delta).toContain('spawn failed:') - expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) - const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts - expect(accounting.size).toBe(0) + expect(task.readOutput().delta).toContain('spawn failed: undefined') + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + }) }) it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => { @@ -284,7 +597,7 @@ describe('background sandbox facts', () => { it('a foreground runner failure throws the fail-closed error, never a task result', async () => { // The wrap's runner prefix on a failed run means the SANDBOX broke and // the command never ran — the late twin of the confine-time throw, with - // the runner's own first stderr line carried as the cause. + // the matched fatal stderr line carried as the cause. const { bash } = await setup() const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' })) await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) @@ -315,7 +628,7 @@ describe('background sandbox facts', () => { let call = 0 const { bash } = await setup({}, (argv) => { const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'> - return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE } + return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE } }) const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' })) const quick = bash.start(bash.resolve({ command: 'true' })) diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 62b1569ee7..c03e407986 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug expect(existsSync(join(outside, 'denied.txt'))).toBe(false) }) + it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => { + const workdir = await tempDir(homedir()) + const outside = await tempDir(homedir()) + const hook = join(workdir, 'bash-env-hook.sh') + const insideProbe = join(workdir, 'hook-ran.txt') + const outsideProbe = join(outside, 'escaped.txt') + await writeFile(hook, [ + 'printf hook > "$DSH_BASH_ENV_INSIDE"', + 'printf escaped > "$DSH_BASH_ENV_OUTSIDE"', + '', + ].join('\n')) + const bash = await sandboxedBash(workdir, 'workspace-write') + + await bash.run(bash.resolve({ + command: 'true', + env: { BASH_ENV: hook }, + dshEnv: { + DSH_BASH_ENV_INSIDE: insideProbe, + DSH_BASH_ENV_OUTSIDE: outsideProbe, + }, + })) + + expect(readFileSync(insideProbe, 'utf8')).toBe('hook') + expect(existsSync(outsideProbe)).toBe(false) + }) + it('classifies a background denial once the task settles', async () => { const workdir = await tempDir(homedir()) const bash = await sandboxedBash(workdir, 'read-only') diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index 4a2c37ad91..978de64ed1 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash/README.md -README.md: d7bf746969f52000fe298b65b995b7c631d8001c -README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c +README.md: 88f519a21a0889d6b7649502c51077940c23709f +README.zh.md: 294044692133da8baa57583146352e84c1ff9946 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index d7bf746969..88f519a21a 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -25,7 +25,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su | `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. | | `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. | -Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit — see the HMR-safety tests. +Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit. ## Vocabulary @@ -35,6 +35,8 @@ The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, th `stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract. + ## Model Experience Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index a7c0cac0bc..2940446921 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -4,7 +4,7 @@ **bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 -本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): +本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): | 包 | 职责 | |---|---| @@ -25,7 +25,7 @@ | `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | | `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | -实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。 +实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。 ## 词汇 @@ -33,7 +33,9 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 + +导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。 ## 模型体验 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index b8ff310f01..71e9ed8d9f 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 4f8ae112a9..904c427ed0 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -22,6 +22,8 @@ export type { DshEnvironment, DshEnvironmentKey, } from './types.ts' +export { parseExitStatus } from './render.ts' +export type { ParsedExitStatus } from './render.ts' declare module 'cordis' { interface Context { diff --git a/packages/bash/bash/src/render.ts b/packages/bash/bash/src/render.ts new file mode 100644 index 0000000000..8f7edcbe1a --- /dev/null +++ b/packages/bash/bash/src/render.ts @@ -0,0 +1,42 @@ +/** + * Shared rendering helpers for the shell tools (`dsh-tool-bash`, + * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers + * emit and the presentation layer parses back. + * @module @deepseek-ai/dsh-bash/render + */ + +/** + * The exit status recovered from a rendered result, with the output body that + * status was split off from. + */ +export type ParsedExitStatus = + & { body: string } + & ({ exitCode: number } | { signal: string }) + +/** + * Split a rendered shell-tool result string into its output body and the + * structured exit status — the inverse of the `[exit code: N]` / + * `[killed by signal: X]` markers the shell tools' renderers append. A killed + * marker yields `signal`; otherwise a non-zero marker yields `exitCode`; + * absent both means a clean exit 0. + * + * The consumed marker is removed from `body` because a terminal presentation + * shows the exit status as its own pill: leaving the marker in the output + * would render the exit twice. Other markers (timeout, sandbox denial) carry + * facts no pill shows, so they stay in the body. + * + * Replay only retains the rendered content text, not the original + * `BashRunResult`, so terminal presentation must recover the exit pill here. + * Requiring a leading newline and the end of the string keeps ordinary output + * that merely ends with marker-like text from matching unless the final line + * is indistinguishable from a real marker. + * @param text - rendered model-facing shell-tool result. + * @returns the marker-free body plus the recovered terminal exit code or signal. + */ +export function parseExitStatus(text: string): ParsedExitStatus { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { body: text, exitCode: 0 } +} diff --git a/packages/bash/bash/tests/render.spec.ts b/packages/bash/bash/tests/render.spec.ts new file mode 100644 index 0000000000..3a9060d289 --- /dev/null +++ b/packages/bash/bash/tests/render.spec.ts @@ -0,0 +1,36 @@ +/** + * Shared exit-status parse contract: the inverse of the `[exit code: N]` / + * `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append. + * Both tools' presenter suites round-trip their own renderers through this + * parse; this spec pins the parse's own edges (marker-like output, body + * slicing) once, at the seam that owns it. + */ + +import { describe, expect, it } from 'vitest' +import { parseExitStatus } from '../src/render.ts' + +describe('parseExitStatus', () => { + it('recovers a clean exit 0 with the body verbatim when no marker is present', () => { + expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 }) + expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 }) + }) + + it('recovers a non-zero exit and strips only its marker from the body', () => { + expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 }) + // The marker needs the leading newline and the end of the string, so a + // clean result whose output merely ENDS in marker-like text is not read + // as a failure and the text stays in the body. + expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 }) + }) + + it('recovers a signal kill ahead of any non-zero exit marker', () => { + expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' }) + // A fake signal marker with no leading newline is output, not a kill. + expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 }) + }) + + it('keeps markers no pill shows (timeout) in the body', () => { + expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]')) + .toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 }) + }) +}) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml new file mode 100644 index 0000000000..ef6a984d07 --- /dev/null +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md +README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf +README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md new file mode 100644 index 0000000000..2914c46ab9 --- /dev/null +++ b/packages/bash/pwsh-local/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +English | [中文](README.zh.md) + +Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. + +The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + +The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn. + +## Config + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## Behavior (and where it came from) + +The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: + +- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. +- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. + +## Model Experience + +Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`. +- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. +- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. + +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md new file mode 100644 index 0000000000..ce9696b276 --- /dev/null +++ b/packages/bash/pwsh-local/README.zh.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。 + +命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。 + +包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## 行为(及其由来) + +作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: + +- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 +- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 +- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 + +## 模型体验 + +间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +无直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知局限与延期工作 + +- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。 +- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。 +- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 +- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 +- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 + +清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json new file mode 100644 index 0000000000..f65d524904 --- /dev/null +++ b/packages/bash/pwsh-local/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-local", + "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts new file mode 100644 index 0000000000..316d2c8651 --- /dev/null +++ b/packages/bash/pwsh-local/src/index.ts @@ -0,0 +1,288 @@ +/** + * Local PowerShell implementation of the bash executor seam. Each command runs + * as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed + * process spawned through `ctx.subprocess`; the executor owns command + * defaulting, deadlines and cause classification, the model-friendly terminal + * environment, and the model-facing stdout/stderr merge for background reads. + * + * The command string is passed as ONE argv element to `-Command`: PowerShell + * itself parses the text, and no intermediate shell exists, so there is no + * shell-quoting layer to escape (the `bash -c` string domain has no + * equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + * + * @module @deepseek-ai/dsh-pwsh-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolvePwshPath } from './resolve.ts' + +/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ +/** + * Model-friendly environment overrides for PowerShell: disable colors and + * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is + * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** + * UTF-8 output pinning prepended to every command. The subprocess collector + * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort + * executable fallback) writes the console/OEM code page by default, which + * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The + * statements ride on line 1 after `; ` separators so PowerShell error line + * numbers stay accurate. + */ +export const ENCODING_PREAMBLE = + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} + +/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ +type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'> + +// Resolution lives in its own dependency-free module so the repository's +// coverage-gate probe shares the exact definition the suites use. +export { candidatePwshPaths, resolvePwshPath } from './resolve.ts' + +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`pwsh-local: ${name} must be a positive finite number`) + } +} + +/** + * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill + * files, and process-tree termination are the subprocess service's mechanics; + * this executor supplies their configured budgets per spawn. + */ +export class PwshLocalExecutor extends BashExecutor { + static inject = ['subprocess'] + + static Config: z<Config> = z.object({ + cwd: z.string(), + timeoutMs: z.number().default(120_000), + maxTimeoutMs: z.number().default(600_000), + maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), + graceMs: z.number().default(DEFAULT_GRACE_MS), + pwshPath: z.string(), + }) + + /** Validated config (schemastery applied the defaults before construction). */ + readonly config: ResolvedConfig + + /** The pwsh executable resolved once at construction. */ + readonly pwshPath: string + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery fills these fields before construction; the type does not encode that step. + this.config = config as ResolvedConfig + assertPositiveFinite('timeoutMs', this.config.timeoutMs) + assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) + assertPositiveFinite('graceMs', this.config.graceMs) + this.pwshPath = resolvePwshPath(this.config.pwshPath) + } + + /** + * Resolve a request into a fully-specified spec: fill `workdir` from + * `config.cwd` (else `process.cwd()`), and `timeoutMs` from + * `config.timeoutMs`, capped at `config.maxTimeoutMs`. + */ + resolve(request: BashExecRequest): BashExecSpec { + const timeoutMs = clampTimeout( + request.timeoutMs, + this.config.timeoutMs, + this.config.maxTimeoutMs, + 'pwsh-local: request.timeoutMs', + ) + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) + return { + command: request.command, + workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + timeoutMs, + stdoutMaxBytes, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + + async run(spec: BashExecSpec): Promise<BashRunResult> { + // One deadline combines timeout and upstream cancellation; disposal clears its timer. + using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = PwshLocalExecutor.collected(handle) + // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined + const aborted = d.signal.aborted && !timedOut + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } + } + + start(spec: BashExecSpec): BashProcess { + // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = PwshLocalExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } + + let stdoutOffset = 0 + let stderrOffset = 0 + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done: running.done.then((outcome) => { + // Any signal termination is killed, including a command signaling itself. + if (proc.status === 'running') { + proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed' + } + proc.exitCode = outcome.exitCode + proc.signal = outcome.signal + this.onProcessDone(proc, collected.stderr.readFrom(0).text) + }, (error: unknown) => { + // Background spawn failures settle as killed and surface through the read path. + proc.status = 'killed' + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) + }), + readOutput: (): BashProcessRead => { + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) + stdoutOffset = out.nextOffset + stderrOffset = err.nextOffset + + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // Single newline between sections: stdout chunks usually end with one + // already; add it only when missing. + const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' + const delta = out.text + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') + return { + delta, + lossy: out.lossy || err.lossy, + ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}, + ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}, + } + }, + kill: (): boolean => { + if (proc.status !== 'running') return false + proc.status = 'killed' + running.terminate() + return true + }, + } + return proc + } + + /** + * Settlement hook for subclasses that attach execution facts to a process. + * The base implementation is intentionally empty. Mirrored from + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is + * the declared seam for a future pwsh-confining subclass and has no consumer + * in this package yet. + * @param _proc - the settled process handle. + * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + */ + protected onProcessDone(_proc: BashProcess, _stderr: string): void {} +} +/* jscpd:ignore-end */ + +export default PwshLocalExecutor diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts new file mode 100644 index 0000000000..4bb1c1ea30 --- /dev/null +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`. + * @module @deepseek-ai/dsh-pwsh-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts new file mode 100644 index 0000000000..c6ded2f883 --- /dev/null +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -0,0 +1,60 @@ +/** + * PowerShell executable resolution, dependency-free so non-package consumers + * (the repository's coverage-gate probe in `vitest.config.ts`) can share the + * ONE resolution definition with the executor and its suites — a probe that + * resolved differently from the code under test could exempt a file whose + * suites actually run. + * + * @module @deepseek-ai/dsh-pwsh-local/resolve + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Well-known Windows PowerShell install locations plus PATH entries, newest + * first. Explicitly parameterized (env) so resolution is a pure function of + * its inputs on every platform. + * @param env - the environment to probe; defaults to the process environment. + * @returns candidate `pwsh` executable paths in resolution order. + */ +export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { + const programFiles = env.ProgramFiles ?? 'C:\\Program Files' + const systemRoot = env.SystemRoot ?? 'C:\\Windows' + const candidates = [ + join(programFiles, 'PowerShell', '7', 'pwsh.exe'), + ] + // Microsoft Store installs (and any user-added location) live on PATH; + // entries may carry surrounding quotes from `setx`-style definitions. + for (const entry of (env.PATH ?? '').split(';')) { + const trimmed = entry.trim().replace(/^"|"$/g, '') + if (trimmed.length === 0) continue + candidates.push(join(trimmed, 'pwsh.exe')) + } + // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. + candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + return candidates +} + +/** + * Resolve the pwsh executable this executor spawns. + * @param configured - an explicit `pwshPath` config value, trusted as-is. + * @param env - the environment to probe on Windows; defaults to the process environment. + * @param platform - the platform to resolve for; defaults to the process platform. + * @returns the first existing well-known location on Windows (PowerShell 7 + * install, a PATH entry such as the Microsoft Store install, then Windows + * PowerShell 5.1), else `pwsh` for PATH resolution. + */ +export function resolvePwshPath( + configured?: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (configured !== undefined && configured.length > 0) return configured + if (platform === 'win32') { + for (const candidate of candidatePwshPaths(env)) { + if (existsSync(candidate)) return candidate + } + } + return 'pwsh' +} diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts new file mode 100644 index 0000000000..4552f2eeec --- /dev/null +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -0,0 +1,454 @@ +/** + * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess + * service plus a REAL pwsh executable, exercised through the executor seam + * (`resolve` → `run`/`start`). These verify the world — actual PowerShell + * runs, output capture, truncation and spill, deadlines, kill escalation, and + * the background-handle contract. The suite self-skips when no usable `pwsh` + * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests + * (config validation, executable resolution) run on every platform. PowerShell + * writes CRLF on Windows, so exact text assertions normalize line endings. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import SubprocessService from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ +function samePath(actual: string, expected: string): boolean { + const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + return norm(actual) === norm(expected) +} + +async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config }) + const bash = ctx.bash as PwshLocalExecutor + return { ctx, bash } +} + +/** + * Poll a handle's consuming readOutput until the ACCUMULATED delta contains + * `expected`; returns the accumulation (reads never re-deliver, so the caller + * gets everything produced up to the match). + */ +async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> { + const deadline = Date.now() + timeoutMs + let all = '' + while (Date.now() < deadline) { + all += proc.readOutput().delta + if (lf(all).includes(expected)) return lf(all) + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`) +} + +describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => { + it('trusts an explicit configured path verbatim', () => { + expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe') + expect(resolvePwshPath('pwsh')).toBe('pwsh') + }) + + it('falls through an empty configured path to platform resolution', () => { + // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 + // fallback candidate cannot exist either. + expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + }) + + it('returns pwsh on non-Windows platforms regardless of the environment', () => { + expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh') + expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') + }) + + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { + const candidates = candidatePwshPaths({ + ProgramFiles: 'P:\\Program Files', + SystemRoot: 'S:\\Windows', + PATH: ';"Q:\\quoted store";' + ';', + }) + expect(candidates).toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('Q:\\quoted store', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + // A missing PATH contributes no entries (the empty-string fallback). + expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' })) + .toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + + it('returns the first EXISTING win32 candidate, else pwsh', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'pwsh.exe'), '') + // The existing PATH entry wins over the non-existent Program Files install. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(join(store, 'pwsh.exe')) + // No candidate exists anywhere (SystemRoot points at a non-existent tree, + // so even the Windows PowerShell 5.1 fallback cannot exist) → the + // PATH-resolution fallback. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) + .toBe('pwsh') + }) +}) + +describe('spawn construction (pure, every platform)', () => { + /** A subprocess service that records spawn specs and settles instantly. */ + class CapturingSubprocessService extends SubprocessService { + specs: SubprocessSpawnSpec[] = [] + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.specs.push(spec) + return { + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: () => {}, + waitForExit: async () => true, + } + } + } + + it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessService(ctx) + await ctx.plugin(PwshLocalExecutor) + await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' })) + expect(subprocess.specs).toHaveLength(1) + const { argv } = subprocess.specs[0]! + expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command']) + expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`) + expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') + expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { + it('resolves with output and the effective timeout', async () => { + const { bash } = await setup({ timeoutMs: 5_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output hi' })) + expect(result.exitCode).toBe(0) + expect(lf(result.stdout.text)).toBe('hi\n') + expect(result.timeoutMs).toBe(5_000) + }) + + it('uses config cwd, overridable per call', async () => { + const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) + const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + const { bash } = await setup({ cwd: first }) + const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) + const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second })) + expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true) + }) + + it('defaults cwd to process.cwd()', async () => { + const { bash } = await setup() + const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true) + }) + + it('caps per-call timeouts at maxTimeoutMs', async () => { + const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 })) + expect(result.timeoutMs).toBe(2_000) + }) + + it('rejects invalid numeric config and timeout overrides', async () => { + await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) + await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) + await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + + const { bash } = await setup() + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/) + }) + + it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100) + + // Raw Console writes avoid PowerShell's own line-ending and formatting + // layers, so the byte counts are exact on every platform. + const result = await bash.run(bash.resolve({ + command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)', + stdoutMaxBytes: 500, + })) + + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stdout.truncated).toBe(false) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 })) + expect(result.timedOut).toBe(true) + // Mutually exclusive: a timeout classifies as timedOut, never also aborted. + expect(result.aborted).toBe(false) + expect(result.timeoutMs).toBe(100) + }) + + it('propagates abort signals', async () => { + const { bash } = await setup() + const controller = new AbortController() + const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.aborted).toBe(true) + // Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut. + expect(result.timedOut).toBe(false) + }) + + it('classifies a self-killed command as neither timed out nor aborted', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' })) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + // Windows reports a forced termination without a signal; POSIX reports the + // terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill). + if (process.platform === 'win32') { + expect(result.signal).toBeNull() + } else { + expect(['SIGTERM', 'SIGKILL']).toContain(result.signal) + } + }) + + it('rejects on spawn failure (bad workdir)', async () => { + const { bash } = await setup() + await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) + }) + + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { + const { bash } = await setup() + const spec = bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. + expect(spec.stdin).toBe('piped\n') + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) + const result = await bash.run(spec) + expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n') + }) + + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'Write-Output ok' }) + expect('stdin' in spec).toBe(false) + expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => { + it('start returns immediately with a running handle that settles as completed', async () => { + const { bash } = await setup() + const before = Date.now() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(150) + expect(proc.status).toBe('running') + await proc.done + expect(proc.status).toBe('completed') + expect(proc.exitCode).toBe(0) + }) + + it('threads stdin and extra env into a background process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"', + stdin: 'bg-stdin\n', + env: { BG_VAR: 'bg-env' }, + dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, + })) + const output = await readUntil(proc, '[bg-env][bg-dsh-env]') + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + await proc.done + expect(proc.exitCode).toBe(0) + }) + + it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' })) + const first = await readUntil(proc, 'first\n') + expect(lf(first)).toBe('first\n') + await proc.done + // Read-after-exit returns the remaining buffered output — once. + const second = proc.readOutput() + expect(lf(second.delta)).toBe('second\n') + expect(second.lossy).toBe(false) + expect(proc.readOutput().delta).toBe('') + }) + + it('readOutput marks stderr sections', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput reports stderr-only deltas without a leading newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n') + }) + + it('readOutput adds a separator only when stdout lacks a trailing newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput flags lossy reads and reports stdout spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' })) + await proc.done + const read = proc.readOutput() + // Window slid past offset 0 → lossy, spill path points at the full stream. + expect(read.lossy).toBe(true) + expect(read.stdoutSpillPath).toBeDefined() + }) + + it('readOutput reports stderr spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' })) + await proc.done + const read = proc.readOutput() + expect(read.lossy).toBe(true) + expect(read.stderrSpillPath).toBeDefined() + expect(lf(read.delta)).toContain('[stderr]') + }) + + it('kill() terminates the process tree: true once, false after settlement', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + expect(proc.kill()).toBe(true) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.kill()).toBe(false) + }) + + it('kill() returns false for a naturally completed process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok' })) + await proc.done + expect(proc.status).toBe('completed') + expect(proc.kill()).toBe(false) + }) + + it('a spec.signal abort settles the handle as killed, not completed', async () => { + const { bash } = await setup() + const controller = new AbortController() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + controller.abort() + await proc.done + expect(proc.status).toBe('killed') + }) + + it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' })) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.exitCode).toBeNull() + // PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill. + expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal) + }) + + it('a background spawn failure settles as killed with the error readable on stderr', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' })) + // done resolves (never rejects) even though the process never ran. + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('spawn failed:') + }) +}) + +describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + // The child prints its own pid so the test can probe liveness through the + // public read surface alone. + const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' })) + const pid = Number((await readUntil(proc, '\n')).trim()) + expect(Number.isInteger(pid) && pid > 0).toBe(true) + + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() + expect(() => process.kill(pid, 0)).toThrow() + await proc.done + // POSIX reports the kill as a signal; Windows reports a forced + // termination as exit 1 with no signal (indistinguishable from a crash), + // so the status stamp follows the platform's exit facts. + expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) + + it('service disposal settles running handles and leaves settled ones untouched', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + const finished = bash.start(bash.resolve({ command: 'Write-Output done' })) + await finished.done + expect(finished.status).toBe('completed') + const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + + await managerFiber.dispose() + // A settled process was untouched; the live one was terminated and joined. + expect(finished.status).toBe('completed') + await running.done + expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json new file mode 100644 index 0000000000..53ccc94926 --- /dev/null +++ b/packages/bash/pwsh-local/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 9d53b52d02..43ff188d8d 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md -README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d -README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57 +README.md: 47101e1198d13518c3d82877df8726c1fbf26b82 +README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 29b9fba369..47101e1198 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor. -The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal. The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. @@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. - -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. +Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. @@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`. #### Token effect diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 31f691f7bf..d60ac4b382 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,9 +4,9 @@ 模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 -需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。 -包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 +包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。 插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 @@ -28,26 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 - -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 +每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 @@ -61,7 +42,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques ## 工具仅使用具名参数构建请求 -`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 +`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 ## 权限与升权 @@ -141,7 +122,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### 模型看到的内容 -验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 +验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 #### Token 影响 diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index c34e1c6e7b..2e304ae913 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -21,20 +21,17 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -49,15 +46,14 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/background.ts b/packages/bash/tool-bash/src/background.ts index 6ba67cdfde..0269fe976c 100644 --- a/packages/bash/tool-bash/src/background.ts +++ b/packages/bash/tool-bash/src/background.ts @@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash' */ export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { // TODO(background-infrastructure-outcome): widen BashProcess with an explicit - // infrastructure-failure outcome, then map spawn failures and - // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn - // failure with a signal-less kill and a runner failure with an ordinary - // wrapper exit; real nonzero command exits must remain `completed`. + // infrastructure-failure outcome, then map it to task `failed`. Restricted + // runner failures expose sandbox.runnerFailed, but unconfined spawn failures + // still alias a signal-less kill; real nonzero command exits must remain + // `completed`. if (proc.status === 'killed') { return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 3b3874bc59..91a88c9cca 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,205 +8,39 @@ * @module @deepseek-ai/dsh-tool-bash */ -import { Service, type Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-bash-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' -declare module 'cordis' { - interface Context { - bashEnv: BashEnvRegistry - } -} - export const name = 'tool-bash' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z<Config> = z.object({ enableRunInBackground: z.boolean().default(true), - dshHome: z.string(), }) -/** Model-visible metadata for one managed `DSH_*` environment variable. */ -export interface BashEnvVariable { - /** Concise description of the environment fact represented by the variable. */ - description: string -} - -/** - * A plugin contribution to the managed environment of each model bash call. - * Declared keys make ownership conflicts detectable before the first command; - * `resolve` computes only the values available for the current execution. - */ -export interface BashEnvContributor { - /** Stable contributor name used in diagnostics and duplicate detection. */ - name: string - /** Complete set of `DSH_*` keys this contributor may return. */ - variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>> - /** - * Resolve this contributor's available values for one tool execution. - * @param execution - the bash tool execution and its optional calling agent. - * @returns a partial map containing only keys declared in {@link variables}. - */ - resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>> -} - -/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ -export interface BashEnvVariableInfo extends BashEnvVariable { - /** Contributor that owns the variable. */ - contributor: string - /** Declared `DSH_*` environment variable name. */ - key: DshEnvironmentKey -} - -const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const -const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const -const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const -const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([ - DSH_HOME_ENV, - DSH_SHELL_KEY, - DSH_SESSION_ID_KEY, -]) -const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ - -/** - * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. - * The namespace is rebuilt for every model bash call: ambient `DSH_*` values - * are discarded by the executor, then the registry's current snapshot is - * injected. Built-in shell facts remain owned by the registry itself while - * plugins can register additional, enumerable facts with effect-scoped - * disposal. - */ -export class BashEnvRegistry extends Service { - private readonly contributors = new Map<string, BashEnvContributor>() - private readonly keyOwners = new Map<DshEnvironmentKey, string>() - private readonly dshHome: string - - /** - * Create and install the `ctx.bashEnv` service. - * @param ctx - Cordis context that owns the service and registrations. - * @param config - home-directory configuration for the built-in variables. - */ - constructor(ctx: Context, config: Config = {}) { - super(ctx, 'bashEnv') - this.dshHome = resolveDshHome(config.dshHome) - } - - /** - * Register one environment contributor. Names and keys are unique; built-in - * keys are reserved. Registration is disposed with the calling plugin fiber. - * @param contributor - declared key ownership and per-execution resolver. - * @returns the disposer that unregisters the contribution. - */ - register(contributor: BashEnvContributor): () => void { - const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { - if (contributor.name.trim().length === 0) { - throw new Error('bash env contributor name must be non-empty') - } - if (this.contributors.has(contributor.name)) { - throw new Error(`bash env contributor "${contributor.name}" is already registered`) - } - - const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] - for (const [key, variable] of variables) { - if (!key.startsWith(DSH_ENV_PREFIX) - || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { - throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) - } - if (RESERVED_BASH_ENV_KEYS.has(key)) { - throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) - } - if (variable.description.trim().length === 0) { - throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) - } - const owner = this.keyOwners.get(key) - if (owner !== undefined) { - throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) - } - } - - this.contributors.set(contributor.name, contributor) - for (const [key] of variables) this.keyOwners.set(key, contributor.name) - yield () => { - this.contributors.delete(contributor.name) - for (const [key] of variables) this.keyOwners.delete(key) - } - }.bind(this), 'bashEnv.register()') - return () => void dispose() - } - - /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. - * @param execution - the current tool execution. - * @returns an immutable environment overlay containing built-ins and current contributions. - */ - collect(execution: ToolExecution): DshEnvironment { - const values: Record<DshEnvironmentKey, string> = { - [DSH_HOME_ENV]: this.dshHome, - [DSH_SHELL_KEY]: '1', - } - if (execution.agent !== undefined) { - values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id - } - - for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { - const resolved = contributor.resolve(execution) - for (const [rawKey, value] of Object.entries(resolved)) { - const key = rawKey as DshEnvironmentKey - if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) - } - if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) - } - values[key] = value - } - } - - return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) - } - - // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, - // prompt, or UI code treats list() as an exhaustive environment catalog. - /** - * Enumerate plugin-contributed variables without executing their resolvers. - * @returns declarations sorted by environment variable name. - */ - list(): BashEnvVariableInfo[] { - return [...this.contributors.values()] - .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ - contributor: contributor.name, - description: variable.description, - key: key as DshEnvironmentKey, - }))) - .sort((left, right) => left.key.localeCompare(right.key)) - } -} - /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string @@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { - // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell - // environment plugin; replacing this tool with persistent Bash must not - // remove the managed DSH_* contributor seam. - const bashEnv = new BashEnvRegistry(ctx, config) - bashEnv.register({ - name: 'session-persistence', - variables: { - [DSH_SESSION_JSONL_KEY]: { - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - }, - }, - resolve(execution) { - const agent = execution.agent - if (agent === undefined) return {} - const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} - }, - }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void { ? standingPolicy : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot) - const dshEnv = bashEnv.collect(exec) + const dshEnv = ctx.bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, @@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void { ...request, signal: exec.signal, })) - if (result.aborted) throw new Error('command aborted') + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index eabe681c25..64c63eb3be 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -95,36 +95,9 @@ export function renderProcessRead( } /** - * The exit status recovered from a rendered result, with the output body that - * status was split off from. + * The exit-status parse is the shared marker-contract half of the shell-tool + * rendering story, owned by `@deepseek-ai/dsh-bash` so `dsh-tool-pwsh` reuses + * it (its renderer emits the same markers). Re-exported here to keep + * `../src/render.ts` a single import root for bash-tool consumers. */ -export type ParsedExitStatus = - & { body: string } - & ({ exitCode: number } | { signal: string }) - -/** - * Split a rendered {@link renderResult} string into its output body and the - * structured exit status — the inverse of the status markers it appends. A - * killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`; - * absent both means a clean exit 0. - * - * The consumed marker is removed from `body` because a terminal presentation - * shows the exit status as its own pill: leaving the marker in the output would - * render the exit twice. Other markers (timeout, sandbox denial) carry facts no - * pill shows, so they stay in the body. - * - * Replay only retains the rendered content text, not the original - * `BashRunResult`, so terminal presentation must recover the exit pill here. - * Requiring a leading newline and the end of the string keeps ordinary output - * that merely ends with marker-like text from matching unless the final line - * is indistinguishable from a real marker. - * @param text - rendered model-facing bash result. - * @returns the marker-free body plus the recovered terminal exit code or signal. - */ -export function parseExitStatus(text: string): ParsedExitStatus { - const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } - const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } - return { body: text, exitCode: 0 } -} +export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 5616afcdef..788c086ba6 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(ToolBash) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -172,7 +174,7 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion notice as user/message → task_output collects it', async () => { + it('background: start ack → pending completion notice → task_output collects it', async () => { // The task id is deterministic (a fresh LocalTaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ @@ -192,20 +194,27 @@ describe('bash tool through the agent loop', () => { expect(resultText(firstResult)).toBe('started background task bash-1') // The task settles on its own; the tool-tasks notice listener injects a - // durable plugin-sourced user/message into the owning agent's session - // (settlement may race turn end, so poll for it). + // pending next-step message without waking the idle agent. const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind === 'plugin' - await pollUntil(() => events(agent).some(isNotice)) - const notice = events(agent).find(isNotice)! - expect(notice.data.content.some( + await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin')) + const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')! + expect(pendingNotice.content.some( block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'), )).toBe(true) - expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) + expect(pendingNotice.source).toEqual({ + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: 'bash echo bg-ok [status: completed, exit code: 0]', + }) - // The next turn collects the output through the generic task tool. + // The next turn first admits that notice as user/message, then collects + // the output through the generic task tool. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) + const notice = events(agent).find(isNotice)! + expect(notice.data).toEqual(pendingNotice) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.message.content[0].isError).toBe(false) expect(resultText(readResult)).toContain('bg-ok') diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index a52b3f16f1..4f913d78ee 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -35,6 +36,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -50,6 +52,7 @@ async function setupWithTasks() { await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } @@ -281,6 +285,7 @@ describe('bash tool', () => { await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -300,7 +305,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(/ENOENT/) }) - it('surfaces foreground aborts as isError', async () => { + it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => { const ctx = await setup() const controller = new AbortController() const pending = ctx.tools.execute({ @@ -312,7 +317,10 @@ describe('bash tool', () => { setTimeout(() => { controller.abort() }, 50) const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toMatch(/aborted/) + expect(result.error).toMatchObject({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) }) // Type and required-key violations are rejected by the harness @@ -389,6 +397,7 @@ describe('bash tool', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(BashEnvPlugin) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) @@ -403,6 +412,7 @@ describe('bash tool', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) await ctx.plugin(LocalSubprocessService) @@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const controller = new AbortController() @@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) @@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) @@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(RecordingSandboxExecutor) + await ctx.plugin(BashEnvPlugin) await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') }) @@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'text', text: 'command aborted' }], isError: true }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, ) - expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { @@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no } await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome }) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) + await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingBashExecutor } } diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 00e9195f9f..b122ed58ca 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,21 +26,18 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, - { - "path": "../../util/paths" - }, { "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml new file mode 100644 index 0000000000..39325f5987 --- /dev/null +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md +README.md: 78eb161f77b9524bc577b273abe59db6b931727c +README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md new file mode 100644 index 0000000000..78eb161f77 --- /dev/null +++ b/packages/bash/tool-pwsh/README.md @@ -0,0 +1,124 @@ +# @deepseek-ai/dsh-tool-pwsh + +English | [中文](README.zh.md) + +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). + +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). + +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. + +The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. + +## Tools + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. | +| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | +| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | +| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | +| `run_in_background` | boolean | Return a task id immediately; no timeout applies. | + +`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. + +### Managed shell environment + +Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. + +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. + +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text. + +When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. + +## UI presentation + +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-bash`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe. + +## Model Experience + +### System prompt + +#### What the model sees + +Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section. + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +Small fixed input cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section. + +### Tool schemas + +#### What the model sees + +The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent. + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token. + +### Foreground result + +#### What the model sees + +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`. + +#### Token effect + +Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Background result + +#### What the model sees + +A background start renders exactly `started background task <id>`; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes. + +#### Token effect + +The ack is a fixed short line; task output is bounded per read. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Tool errors + +#### What the model sees + +Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. + +#### Token effect + +Only the failing call adds these retained tokens; an aborted call adds no command output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md new file mode 100644 index 0000000000..17696fe6d9 --- /dev/null +++ b/packages/bash/tool-pwsh/README.zh.md @@ -0,0 +1,124 @@ +# @deepseek-ai/dsh-tool-pwsh + +[English](README.md) | 中文 + +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 + +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 + +包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 + +插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 + +## 工具 + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `run_in_background` | boolean | 立即返回 task id;不适用超时。 | + +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 + +### Managed shell environment + +每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 + +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 + +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。 + +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 + +## UI presentation + +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的前台结果同样是 `terminal` 卡:退出 marker 变成卡片的退出状态 pill(`exitCode`/`signal`),去 marker 的正文成为卡片输出——与 bash 工具的 terminal 卡故事完全一致,经由 `@deepseek-ai/dsh-bash` 的共享退出状态解析。后台 ack 与执行错误保持 `generic` 卡,以 `console` 围栏包裹渲染输出。这些 presenter 是纯函数且可重放。 + +## Model Experience + +### System prompt + +#### What the model sees + +本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +插件激活期间每次请求的固定小额输入成本。 + +#### KV Cache effect + +注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。 + +### Tool schemas + +#### What the model sees + +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 + +#### Token effect + +工具可见的每个请求上的固定 schema 成本。 + +#### KV Cache effect + +可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。 + +### Foreground result + +#### What the model sees + +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。 + +#### Token effect + +调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Background result + +#### What the model sees + +后台启动精确渲染为 `started background task <id>`;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。 + +#### Token effect + +ack 是固定短行;任务输出按读取有界。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Tool errors + +#### What the model sees + +校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 + +#### Token effect + +只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +## Known Limitations and Deferred Work + +- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json new file mode 100644 index 0000000000..0c25317faa --- /dev/null +++ b/packages/bash/tool-pwsh/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh", + "description": "Model-facing pwsh tool over the bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/tool-pwsh/src/background.ts b/packages/bash/tool-pwsh/src/background.ts new file mode 100644 index 0000000000..5e3464f76b --- /dev/null +++ b/packages/bash/tool-pwsh/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-task adaptation for background pwsh process handles — the shell-agnostic + * twin of `dsh-tool-bash`'s background adaptation. + * + * @module @deepseek-ai/dsh-tool-pwsh/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { + // TODO(background-infrastructure-outcome): widen BashProcess with an explicit + // infrastructure-failure outcome, then map spawn failures and + // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn + // failure with a signal-less kill and a runner failure with an ordinary + // wrapper exit; real nonzero command exits must remain `completed`. + if (proc.status === 'killed') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts new file mode 100644 index 0000000000..a68d5d2e35 --- /dev/null +++ b/packages/bash/tool-pwsh/src/index.ts @@ -0,0 +1,318 @@ +/** + * Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for + * Windows compositions where a PowerShell executor (e.g. + * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is + * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. + * + * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: + * foreground and `run_in_background` execution (background handles register + * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment + * through the shared `bash-env` registry, and the bash marker/truncation + * rendering story. UI presentation mirrors the bash tool's too: a completed + * foreground call is a terminal card with the parsed exit-status pill, using + * the shared exit-status parse from `@deepseek-ai/dsh-bash`. + * + * @module @deepseek-ai/dsh-tool-pwsh + */ + +import { isAbsolute, resolve as resolvePath } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-bash-env' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { parseExitStatus } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { renderPwshProcessRead, renderPwshResult } from './render.ts' + +declare module '@deepseek-ai/dsh-tasks' { + interface TaskKindMap { + pwsh: 'pwsh' + } +} + +export const name = 'tool-pwsh' +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] + +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} + +/** Runtime configuration schema for the pwsh tool plugin. */ +export const Config: z<Config> = z.object({ + enableRunInBackground: z.boolean().default(true), +}) + +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ +interface PwshToolArgs { + command: string + description: string + timeoutMs?: number + workdir?: string + run_in_background?: boolean +} + +/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ +interface PwshForegroundResult { + kind: 'foreground' + exitCode: number | null + signal: NodeJS.Signals | null + timedOut: boolean + aborted: boolean + timeoutMs: number + stdout: { text: string; truncated: boolean; spillPath?: string } + stderr: { text: string; truncated: boolean; spillPath?: string } +} + +/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ +function validatePwshArgs(args: PwshToolArgs): void { + if (args.command.trim().length === 0) { + throw new Error('invalid command: expected a non-empty string') + } + if (args.description.trim().length === 0) { + throw new Error('invalid description: expected a non-empty string') + } + if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { + throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + } +} +/* jscpd:ignore-end */ + +function pwshDescription(backgroundEnabled: boolean): string { + const background = backgroundEnabled + ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' + : 'Background execution is not available; long-running commands must finish within the timeout.' + return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + + background +} + +/** + * Resolve an explicit workdir first, making a relative one session-workspace-relative; + * otherwise use the session header cwd and leave executor defaulting as the fallback. + */ +function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { + const headerCwd = exec.agent?.session.header.cwd + if (modelWorkdir === undefined) return headerCwd + if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) { + return resolvePath(headerCwd, modelWorkdir) + } + return modelWorkdir +} + +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + kind: 'foreground', + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ + stdout: output(result.stdout), + stderr: output(result.stderr), + } +} + +/** Canonical background-handle properties shared by the pwsh output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const +/* jscpd:ignore-end */ + +export function apply(ctx: Context, config: Config = {}): void { + const backgroundEnabled = config.enableRunInBackground ?? true + + ctx.systemPrompt.section({ + name: 'tool:pwsh', + order: 105, + text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', + }) + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: pwshDescription(backgroundEnabled), + parameters: { + command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, + description: { + type: 'string', + required: true, + 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"; "Get-Process" → "List running processes".', + }, + 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.' }, + ...backgroundEnabled ? { + run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, + } : {}, + }, + output: { + // The foreground result wire shape mirrors dsh-tool-bash's by contract — + // consumers of one must accept the other (see the pwsh-tool-and-executor + // Agent Note). + /* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */ + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + }, + }, + ], + }, + /* jscpd:ignore-end */ + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderPwshResult(value), + }], + }, + /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ + async execute(args: PwshToolArgs, exec) { + validatePwshArgs(args) + const workdir = resolveWorkdir(args.workdir, exec) + const request = { + command: args.command, + ...workdir !== undefined ? { workdir } : {}, + ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv: ctx.bashEnv.collect(exec), + } + if (args.run_in_background === true) { + // Undeclared keys are allowed, so schema omission also needs enforcement. + if (!backgroundEnabled) { + throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + } + const tasks = ctx.get('tasks') + if (tasks === undefined) { + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + } + // The caller owns cancellation until ctx.tasks commits detached ownership. + /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; + pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts + already-aborted signals first, so this mirror-only guard has no reachable trigger. */ + if (exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + /* v8 ignore end */ + // Task preflight finishes before the starter can spawn a process. + const id = tasks.start({ + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + run: () => { + const proc = ctx.bash.start(ctx.bash.resolve(request)) + return { + cancel: () => void proc.kill(), + done: proc.done.then(() => processOutcome(proc)), + readOutput: () => renderPwshProcessRead(proc.readOutput()), + } + }, + }) + return { kind: 'background' as const, taskId: id } + } + const result = await ctx.bash.run(ctx.bash.resolve({ + ...request, + signal: exec.signal, + })) + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + return canonicalPwshResult(result) + }, + /* jscpd:ignore-end */ + /* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */ + presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => { + // Background acknowledgements carry no terminal exit status; the generic + // card mirrors the bash tool's background presentation. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, + } + }, + /* jscpd:ignore-end */ + /* jscpd:ignore-start -- the completed-result presentation mirrors presentBashResult's by design (Agent Note). */ + presentResult: (args: unknown, result: ToolResult): ToolResultView | undefined => { + const block = result.content.length === 1 ? result.content[0] : undefined + if (block === undefined || block.type !== 'text') return undefined + const raw = block.text + const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true + // Background acknowledgements and errors have no terminal exit status. + if (isBackground || result.isError) { + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } + } + // The exit marker becomes the card's exit pill, so it leaves the output body. + const { body, ...exit } = parseExitStatus(raw) + return { card: 'terminal', output: body, ...exit } + }, + /* jscpd:ignore-end */ + })) +} diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts new file mode 100644 index 0000000000..dd6370b490 --- /dev/null +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`. + * @module @deepseek-ai/dsh-tool-pwsh/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts new file mode 100644 index 0000000000..42f4bc696c --- /dev/null +++ b/packages/bash/tool-pwsh/src/render.ts @@ -0,0 +1,81 @@ +/** + * Model-facing result rendering for the pwsh tool — the PowerShell twin of + * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked + * stderr section, truncation notices with spill paths, then exit-status + * markers. Non-zero exits are reported, not errored — the model decides how to + * react; only infrastructure failures (spawn errors, aborts) surface as + * isError results. + * + * @module @deepseek-ai/dsh-tool-pwsh/render + */ + +import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** The renderable foreground result shape (the schema-derived value, no `kind`). */ +export interface RenderablePwshResult { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * stderr section, then exit-status markers, matching the bash tool's story — + * a clean exit (0, no signal) produces no marker. + * @param result - the completed foreground run from the executor. + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderPwshResult(result: RenderablePwshResult): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // A command may trap the termination and exit 0 after timeout; still report interruption. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * sees: the incremental delta, plus the lossy-read notice (with full-stream + * spill paths) when in-memory truncation dropped unread bytes. + * @param read - one incremental read from the process handle. + * @returns the delta text with any loss notice appended. + */ +export function renderPwshProcessRead(read: BashProcessRead): string { + const notices: string[] = [] + if (read.lossy) { + const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) + notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) + } + if (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts new file mode 100644 index 0000000000..c347866f50 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -0,0 +1,154 @@ +/** + * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the + * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell + * process. These verify the world — actual commands run, stdout/stderr come + * back, exit codes render, timeouts abort, background tasks settle through the + * generic task runtime, and per-session cwd resolution works. The suite + * self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without + * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage + * gate. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' + +const testToolSignal = new AbortController().signal + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +let dir: string +let ctx: Context + +let callCounter = 0 +function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) { + return ctx.tools.execute({ + signal: signal ?? testToolSignal, + callId: CallId(`it-${++callCounter}`), + name, + arguments: args, + ...agentObj ? { agent: agentObj as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-')) + await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n') + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) + await ctx.plugin(ToolPwsh) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + + it('runs a command and returns stdout with no marker on a clean exit', async () => { + const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) + expect(lf(text(result))).toBe('hi\n') + }) + + it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { + const result = await call('pwsh', { + command: '[Console]::Error.WriteLine("boom"); exit 3', + description: 'fail loudly', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]') + }) + + it('resolves relative paths in the session workspace', async () => { + const result = await call('pwsh', { + command: 'Get-Content greeting.txt', + description: 'read greeting', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('hello pwsh\n') + }) + + it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { + const result = await call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + timeoutMs: 100, + }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected a timed-out foreground result') + expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false }) + // Windows reports the forced termination as exit 1 without a signal; + // POSIX reports SIGTERM — the timeout marker is the stable fact. + expect(lf(text(result))).toContain('[timed out after 100ms]') + }) + + it('an upstream cancellation aborts the run', async () => { + const controller = new AbortController() + const pending = call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + }, agent(), controller.signal) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) + + it('a background run settles through the REAL task_output tool', async () => { + const started = await call('pwsh', { + command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done', + description: 'background greeting', + run_in_background: true, + }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toMatchObject({ kind: 'background' }) + const taskId = (started.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so collect incrementally — + // the same two-step shape as the bash background suite. + const deadline = Date.now() + 10_000 + let output = '' + while (Date.now() < deadline) { + const read = await call('task_output', { task_id: taskId }) + output += text(read) + if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(output).toContain('bg-done') + expect(output).toContain('[status: completed, exit code: 0]') + }) +}) diff --git a/packages/bash/tool-pwsh/tests/loader.spec.ts b/packages/bash/tool-pwsh/tests/loader.spec.ts new file mode 100644 index 0000000000..7037162579 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/loader.spec.ts @@ -0,0 +1,63 @@ +/** + * REAL-composition tier (packages/AGENTS.md): boot the examples-owned + * tool-pwsh Loader fixture as a subprocess through the same app/boot path a + * deployment uses, execute real foreground and background pwsh commands + * through the tool registry, and assert the assembled model-visible surface: + * schema, prompt section, and rendered results. Self-skips when no `pwsh` + * executable exists (a CI accommodation for hosts without PowerShell). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +interface PwshLoaderReport { + schemaHasRunInBackground: boolean + promptHasMarkerSection: boolean + foregroundText: string + backgroundText: string +} + +describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => { + it('registers the pwsh surface and renders real foreground and background results', async () => { + let report: PwshLoaderReport | undefined + const { stderr } = await runLoaderSmoke({ + label: 'tool-pwsh loader smoke', + tempDirPrefix: 'tool-pwsh-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(report).toBeDefined() + expect(report).toMatchObject({ + schemaHasRunInBackground: true, + promptHasMarkerSection: true, + }) + expect(report?.foregroundText).toBe('loader-ok\n') + expect(report?.backgroundText).toContain('loader-bg-ok') + expect(report?.backgroundText).toContain('[status: completed, exit code: 0]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts new file mode 100644 index 0000000000..71e3124e7e --- /dev/null +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -0,0 +1,714 @@ +/** + * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, + * exercised through `ctx.tools.execute()` so nothing bypasses the tool + * registry. The fake executor makes every seam outcome scriptable — output + * text, truncation, timeout, abort, nonzero exits, background handles — so + * these tests verify the schema, argument validation, workdir derivation, + * managed `DSH_*` collection, abort translation, canonical result projection, + * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * is pinned separately in integration.spec.ts. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve as resolvePath } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' +import type { BashProcessRead } from '@deepseek-ai/dsh-bash' +import { processOutcome } from '../src/background.ts' +import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' + +const testToolSignal = new AbortController().signal + +/** + * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` + * returns the armed foreground script, `start()` returns the armed background + * handle. + */ +class FakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + specs: BashExecSpec[] = [] + startCalls = 0 + handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n') + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(spec: BashExecSpec): Promise<BashRunResult> { + this.specs.push(spec) + return this.handler(spec) + } + + override start(spec: BashExecSpec): BashProcess { + this.startCalls++ + this.specs.push(spec) + return this.backgroundHandler(spec) + } +} + +/** A successful run result over the given stdout; overrides script the failure shapes. */ +function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A settled successful background handle; overrides script failure shapes. */ +function fakeProcess(delta = 'bg-ok\n'): BashProcess { + let consumed = false + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => { + if (consumed) return { delta: '', lossy: false } + consumed = true + return { delta, lossy: false } + }, + kill: () => false, + } +} + +/** A running background handle whose kill() settles it as killed (like a real task_kill). */ +function killableProcess(): BashProcess { + let resolveDone: () => void = () => {} + const done = new Promise<void>((resolve) => { resolveDone = resolve }) + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + if (proc.status !== 'running') return false + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + } + return proc +} + +async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */ +async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** + * Build a fake {@link Agent} with the shared agent/session identity, give it a + * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + */ +function registerFakeAgent(ctx: Context, sessionId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) + const agent = { + id, + ctx: scopeFiber.ctx, + session: { id, header: { version: 0, id, createdAt: 0 } }, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: Agent) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +async function callUntilText( + ctx: Context, + name: string, + args: unknown, + expected: string, + timeoutMs = 5_000, +): Promise<Awaited<ReturnType<typeof call>>> { + const deadline = Date.now() + timeoutMs + let last: Awaited<ReturnType<typeof call>> | undefined + while (Date.now() < deadline) { + last = await call(ctx, name, args) + if (text(last).includes(expected)) return last + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`) +} + +describe('registration', () => { + it('registers the pwsh tool with its prompt section and schema', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') + expect(schema).toBeDefined() + expect(schema?.description).toContain('PowerShell command') + expect(schema?.parameters.properties).toMatchObject({ + command: { type: 'string' }, + description: { type: 'string' }, + timeoutMs: { type: 'number' }, + workdir: { type: 'string' }, + run_in_background: { type: 'boolean' }, + }) + expect(schema?.parameters.required).toEqual(['command', 'description']) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers') + expect(prompt).toContain('without a signal marker') + }) + + it('stays pending until ctx.bash exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + const fiber = await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('argument validation', () => { + it('rejects a blank command or description and a non-positive timeoutMs', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) + .toContain('invalid timeoutMs: expected a positive number') + }) +}) + +describe('execution through the bash seam', () => { + it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + const { ctx, bash } = await setup({}, dshHome) + bash.handler = () => runResult('hi\n') + const agent = registerFakeAgent(ctx, 'session-1') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + const result = await call(ctx, 'pwsh', { + command: 'Write-Output hi', + description: 'say hi', + timeoutMs: 1234, + }, agent) + expect(result.isError).toBe(false) + const request = bash.requests[0] + expect(request?.command).toBe('Write-Output hi') + expect(request?.workdir).toBe('/sessions/s1') + expect(request?.timeoutMs).toBe(1234) + expect(request?.dshEnv).toEqual({ + DSH_HOME: dshHome, + DSH_SHELL: '1', + DSH_SESSION_ID: 'session-1', + }) + expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + }) + + it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + const agent = registerFakeAgent(ctx, 'session-cwd') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) + expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent) + expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) + }) + + it('omits workdir and the session id without an agent, so executor defaulting applies', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) + expect(bash.requests[0]).not.toHaveProperty('workdir') + const dshEnv = bash.requests[0]?.dshEnv + expect(dshEnv).toBeDefined() + expect(dshEnv?.['DSH_SHELL']).toBe('1') + expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String)) + expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID') + }) + + it('forwards exec.signal into the resolved request', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => runResult('ok\n') + await ctx.tools.execute({ + signal: controller.signal, + callId: CallId('call-signal'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'ok' }, + }) + expect(bash.requests[0]?.signal).toBe(controller.signal) + }) + + it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out\n', { + exitCode: 2, + stderr: { text: 'err\n', truncated: false }, + timeoutMs: 5000, + }) + const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toEqual({ + kind: 'foreground', + exitCode: 2, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 5000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: 'err\n', truncated: false }, + }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') + }) + + it('renders a clean exit without a marker and an empty body as (no output)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(text(clean)).toBe('hi\n') + + bash.handler = () => runResult('') + const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' }) + expect(text(empty)).toBe('(no output)') + }) + + it('renders stderr-only output without a stdout prefix', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]') + }) + + it('inserts the separating newline before the stderr section when stdout lacks one', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]') + }) + + it('renders the truncation notice with the spill path, then markers', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]') + + bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) + const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) + // A timeout kill carries both facts, mirroring the bash tool's markers. + expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('renders the truncation notice with (unavailable) when no spill path exists', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]') + }) + + it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }) + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) +}) + +describe('background execution through the task runtime', () => { + it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { + const { ctx } = await setupWithTasks() + const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' }) + expect(text(started)).toBe('started background task pwsh-1') + + const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok') + expect(text(read)).toContain('bg-ok') + // A later read reports the terminal outcome in the generic status line. + const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]') + expect(final.isError).toBe(false) + }) + + it('a running background task is killable through the REAL task_kill tool', async () => { + const { ctx, bash } = await setupWithTasks() + bash.backgroundHandler = () => killableProcess() + await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }) + expect(text(killed)).toBe('requested cancellation of task pwsh-1') + // The cancel reached the process handle; the task settles as killed with + // the signal detail mapped by processOutcome. + const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }) + expect(text(final)).toContain('[status: killed, signal: SIGTERM]') + }) + + it('a background task started by an agent is registered with that agent as owner', async () => { + const { ctx } = await setupWithTasks() + const agent = registerFakeAgent(ctx, 'sess-owner') + const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pwsh-1') + + const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' }) + expect(anon.isError).toBe(true) + expect(text(anon)).toMatch(/belongs to another session/) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent) + expect(killed.isError).toBe(false) + await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan + }) + + it('fails loud when the task runtime is not loaded', async () => { + const { ctx } = await setup() // no LocalTaskService / ToolTasks + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + }) + + it('a pre-aborted call is skipped before the process starts', async () => { + const { ctx, bash } = await setupWithTasks() + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId('call-pre-aborted'), + name: 'pwsh', + arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(bash.startCalls).toBe(0) + }) + + it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => { + // With no control surface, task preflight fails before the executor can spawn. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as FakeBash + + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no control surface is attached') + // Declare-then-execute: the failed preflight means no process ever ran. + expect(bash.startCalls).toBe(0) + }) + + it('enableRunInBackground: false removes the parameter and flips the description', async () => { + const { ctx } = await setup({ enableRunInBackground: false }) + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')! + expect(Object.keys(schema.parameters.properties as Record<string, unknown>)) + .toEqual(['command', 'description', 'timeoutMs', 'workdir']) + expect(schema.description).toContain('Background execution is not available') + expect(schema.description).not.toContain('run_in_background') + + // Schema omission is advertising; execution must also enforce the opt-out. + const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) + expect(forced.isError).toBe(true) + expect(text(forced)).toContain('run_in_background is disabled for this deployment') + const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) + expect(foreground.isError).toBe(false) + }) + + it('applies the built-in background default when apply() receives a bare config', async () => { + // Bypasses the schemastery defaults on purpose: apply() must stand on its + // own `?? true` fallback when embedded programmatically without the schema. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + ToolPwsh.apply(ctx, {}) + const schema = ctx.tools.schemas()[0]! + expect(schema.parameters.properties).toHaveProperty('run_in_background') + expect(schema.description).toContain('task_output') + }) +}) + +describe('UI presentation', () => { + it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const args = { command: 'Write-Output hi', description: 'say hi' } + const result = await call(ctx, 'pwsh', args) + const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) + // A terminal result keeps the RAW bytes (newlines intact) a terminal + // renderer needs; a clean run renders no exit marker, so the body is the + // raw output with a clean exit-0 pill, mirroring the bash tool. + expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 }) + }) + + it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' })) + .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' }) + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' })) + .toMatchObject({ cwd: 'C:\\work' }) + }) + + it('a background pending call renders the generic card like the bash tool', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ + command: 'Start-Sleep -Seconds 60', + description: 'long wait', + run_in_background: true, + })).toEqual({ + card: 'generic', + title: 'Start-Sleep -Seconds 60', + kind: 'execute', + rawInput: 'Start-Sleep -Seconds 60', + content: [{ type: 'text', text: 'long wait' }], + }) + }) + + it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { + const { ctx } = await setup() + const present = ctx.tools.get('pwsh') + const args = { command: 'x', description: 'x' } + expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })) + .toEqual({ card: 'terminal', output: 'oops', exitCode: 3 }) + expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })) + .toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' }) + }) + + it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => { + const { ctx } = await setup() + const args = { command: 'x', description: 'x' } + expect(ctx.tools.get('pwsh')?.presentResult?.( + args, + { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false }, + )).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 }) + }) + + it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => { + const { ctx } = await setup() + const present = ctx.tools.get('pwsh')! + const base = { + aborted: false, + timeoutMs: 1000, + stdout: { text: 'out', truncated: false }, + stderr: { text: '', truncated: false }, + } + const cases = [ + { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } }, + { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } }, + { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } }, + // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0). + { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } }, + ] + for (const c of cases) { + const rendered = renderPwshResult(c.result) + const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) + // Drop card + output; the remaining fields are the parsed exit. + const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } + expect(exit).toEqual(c.expect) + // Whatever the parse consumed is gone from the body, so a card with an + // exit pill never shows the same status twice. + expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /) + } + }) + + it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => { + const { ctx } = await setup() + const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' } + // A successful command may print marker-like text. A clean result appends no marker or + // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. + const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) + expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) + // Same for a fake signal marker with no leading newline. + const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) + expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) + }) + + it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => { + const { ctx } = await setup() + const result = ctx.tools.get('pwsh')!.presentResult!( + { command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true }, + { content: [{ type: 'text', text: 'started background task pwsh-1' }], isError: false }, + ) + expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task pwsh-1\n```' }] }) + }) + + it('presentResult: an isError result is a generic card (no real process exit to report)', async () => { + const { ctx } = await setup() + const out = ctx.tools.get('pwsh')!.presentResult!( + { command: 'x', description: 'x' }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, + ) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) + }) + + it('presentResult falls back to undefined for multi-block or non-text content', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + const args = { command: 'Write-Output hi', description: 'say hi' } + const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false } + expect(definition?.presentResult?.(args, multi as never)).toBeUndefined() + const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false } + expect(definition?.presentResult?.(args, image as never)).toBeUndefined() + }) +}) + +describe('renderPwshProcessRead', () => { + const base: BashProcessRead = { delta: 'out\n', lossy: false } + + it('returns the delta verbatim for a lossless read', () => { + expect(renderPwshProcessRead(base)).toBe('out\n') + expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') + }) + + it('appends the loss notice with the available spill paths', () => { + expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') + expect(renderPwshProcessRead({ + ...base, + lossy: true, + stdoutSpillPath: 'C:\\spill\\out.log', + stderrSpillPath: 'C:\\spill\\err.log', + })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') + }) + + it('reports (unavailable) when a lossy read has no safe spill path', () => { + expect(renderPwshProcessRead({ ...base, lossy: true })) + .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') + }) + + it('an empty lossy delta is the notice alone', () => { + expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) + .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') + }) + + it('inserts the separating newline only when the delta lacks one', () => { + expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + }) +}) + +describe('processOutcome', () => { + function settled(over: Partial<BashProcess>): BashProcess { + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...over, + } + } + + it('maps a signal-killed process to killed with the signal detail', () => { + expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' }))) + .toEqual({ status: 'killed', detail: 'signal: SIGTERM' }) + }) + + it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => { + expect(processOutcome(settled({ status: 'killed', exitCode: null }))) + .toEqual({ status: 'killed', detail: 'killed before exit' }) + }) + + it('maps a completed process to its exit code', () => { + expect(processOutcome(settled({ exitCode: 3 }))) + .toEqual({ status: 'completed', detail: 'exit code: 3' }) + }) + + it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => { + expect(processOutcome(settled({ exitCode: null }))) + .toEqual({ status: 'completed', detail: 'exit code: 0' }) + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json new file mode 100644 index 0000000000..61b2c69448 --- /dev/null +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../tasks/tasks" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 8bf351220c..46e0b9d252 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -60,16 +60,16 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain ## Styling -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). 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. Product copy is Chinese; code comments are English. ## Testing and coverage The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md). -- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore. -- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't. -- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template). -- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs. +- Client source packages are inside the per-file 100% coverage gate (`pnpm run test:coverage`). Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore. +- Component specs render with realistic props or a driven fixture runtime and assert user-visible behavior, not class names, hook internals, or render counts. +- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line; the shared config stays node-env. +- Each tier asserts its own layer. Data-layer semantics belong to the runtime and host suites; component specs cover presentation behavior. ## Before you push: the local check ladder @@ -88,7 +88,7 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. -4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). +4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. 5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. ## New component checklist diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 210228ae88..503a747087 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: 31d884c04a8b0233713b77b82b3d9cc7052003ca -README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4 +README.md: b950772d4cad6d873426f8aee6416fa56afca2ee +README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d diff --git a/packages/client/README.md b/packages/client/README.md index 31d884c04a..b950772d4c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -2,33 +2,38 @@ English | [中文](README.zh.md) -The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`. +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-<name>`. -| Package | Role | ctx key / slot | -|---|---|---| -| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) | -| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) | -| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) | -| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` | -| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` | -| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) | -| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` | -| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) | -| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` | -| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) | -| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` | -| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) | -| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) | -| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) | -| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) | -| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` | -| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` | -| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) | -| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) | -| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` | -| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) | -| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) | -| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) | -| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) | +| Package | Purpose | +|---|---| +| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. | +| [`modules/`](modules/README.md) | Loads browser-side client modules. | +| [`web-react/`](web-react/README.md) | Connects the shell runtime to React rendering. | +| [`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. | +| [`schema-form/`](schema-form/README.md) | Provides schema-backed draft handling for settings editors. | +| [`test-runtime/`](test-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-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-goal/`](ui-goal/README.md) | Presents and manages the current goal. | +| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. | +| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. | +| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. | +| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | +| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | +| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | +| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | +| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | +| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | +| [`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-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | -Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer. +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. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 9c95db5293..8f1f7f4677 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -2,33 +2,38 @@ [English](README.md) | 中文 -dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。 +dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和特性插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为命名成 `@deepseek-ai/dsh-client-<name>` 的**产品**包。 -| 包 | 角色 | ctx 键/slot | -|---|---|---| -| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) | -| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) | -| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) | -| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` | -| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` | -| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) | -| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` | -| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) | -| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` | -| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) | -| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` | -| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) | -| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) | -| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) | -| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) | -| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` | -| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` | -| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) | -| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) | -| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` | -| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) | -| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) | -| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) | -| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) | +| 包 | 目的 | +|---|---| +| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 | +| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 | +| [`web-react/`](web-react/README.md) | 连接 shell 运行时与 React 渲染。 | +| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 | +| [`runtime/`](runtime/README.md) | 为会话、Workspace 和 UI 组合提供共享客户端服务。 | +| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 | +| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 | +| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 | +| [`test-runtime/`](test-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-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | +| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 | +| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | +| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 | +| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 | +| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 | +| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 | +| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 | +| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | +| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 | +| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | +| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | +| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | +| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | +| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | +| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | +| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | -特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。 +每个子文档负责自身的契约和详细行为。[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)负责跨包组合与加载决策。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..05b9bb4141 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: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e +README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..1393e79aac 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses 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`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) 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 subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. 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. 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) 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); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`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 requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-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. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +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, the CLI-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. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. 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). -## Keyless fixture +## `/api` WebSocket downlinks -Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. +`/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. ## Model Experience @@ -22,5 +22,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open. -- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then. +- **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. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..70380ceba1 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。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 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 -## 无密钥 fixture +## `/api` WebSocket 下行 -任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 +`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 ## 模型体验 @@ -22,5 +22,4 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust ## 已知限制与暂缓事项 -- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。 -- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。 +- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..929464fdc8 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)", + "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", "version": "0.0.1", "private": true, "type": "module", @@ -34,15 +34,14 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "ws": "^8.21.0" }, "files": [ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "^0.0.1", @@ -52,6 +51,7 @@ "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/ws": "^8.18.1", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index 30e91522a2..f34aa231d4 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,8 +1,14 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; browser-side path - * literals currently live in the apiproxy client layer (out of scope here). + * The node half registers this prefix on the web server; both halves share the + * event paths below for the browser WebSocket downlinks. */ /** Route prefix owning every api request (`/api` and `/api/<anything>`). */ 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 ecb180dca7..4e897ccf87 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -4,7 +4,7 @@ * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page. The Host fence binds every request, * browser-looking or not: over plain HTTP a browser attaches neither Origin - * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * nor Fetch-Metadata to reads (images and navigations — those * headers go only to trustworthy destinations), so an unmarked request may * still be a rebound browser read and Host is the one header rebinding cannot * forge. Non-browser and remote clients pass the same fence via loopback, the @@ -97,7 +97,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read // 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 // server. There is no marker shortcut — a browser read over plain HTTP - // (EventSource, images, navigations) arrives with neither Origin nor + // (images and navigations) arrives with neither Origin nor // Fetch-Metadata, indistinguishable from curl, and its response is readable // by the rebound page. const host = header(request.headers, 'host') diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 04e9fcb713..16ff76667c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,7 +12,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, + ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi, @@ -34,6 +34,7 @@ export { export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' +export type { MessageId } from '@deepseek-ai/dsh-llm/brand' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..18d22f878d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -126,7 +126,7 @@ export class ConnectionController { try { // Strict readiness handshake (audit C2): describe proves unary reachability, onOpen - // proves each SSE transport is established (response headers in, before any frame) — + // 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). diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dff0069b87..be81b54b17 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -27,7 +27,7 @@ import type { // 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 { foldSurface } from '@deepseek-ai/dsh-session/surface' +import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -167,7 +167,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin { lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' }, { lineNumber: 35, line: ' const search = searchCardModel(block)' }, { lineNumber: 52, line: ' search={search}' }, - { lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" }, + { lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" }, ], }, ] @@ -339,7 +339,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } /** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50), - * mixing reasoning blocks / tool call+result / steering / context. */ + * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record<string, unknown>[] = [] let time = Date.now() - 3_600_000 @@ -358,8 +358,14 @@ 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. + push({ + type: 'request/context', + data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 }, + }) for (let turn = 0; turn < 60; turn++) { - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) const userSeq = push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)), @@ -393,9 +399,6 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } }) push({ type: 'step/end', data: { turn, step: 0 } }) } - if (turn % 13 === 6) { - push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}:fixture steering 消息。`)) } }) - } 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 @@ -403,7 +406,7 @@ function buildAlphaLog(): SessionEvent[] { // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) }) push({ type: 'step/start', data: { turn, step: 0 } }) push({ @@ -440,7 +443,7 @@ function buildAlphaLog(): SessionEvent[] { + 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n' + 'return { listing, demo }' const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' }) - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) }) push({ type: 'step/start', data: { turn, step: 0 } }) push({ @@ -685,7 +688,7 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi * Fixture parallel of the plan unit's double-event fold: `command/run` * records named `plan` set the wanted target (`off` → false, else true); * `plan/mode` commits and clears it. `wanted` is exposed for the prompt - * boundary (the fixture's agent/step parallel). + * boundary (the fixture's step/start parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false @@ -822,6 +825,62 @@ interface FixtureRequestContext { contextWindow?: number } +interface FixtureContextBreakdownProjection { + systemTokens: number + toolsTokens: number + messageTokens: number +} + +/** Fixed token-meter heuristic constants mirrored by this client-only fixture. */ +const CHARS_PER_TOKEN = 4 +const BLOCK_OVERHEAD = 4 +const ROLE_OVERHEAD = 4 + +/** Price fixture content with token-meter's fixed-density heuristic. */ +function estimateFixtureContent(blocks: readonly ContentBlock[]): number { + const densityPrice = (value: string): number => Math.ceil(value.length / CHARS_PER_TOKEN) + return blocks.reduce((tokens, block) => { + if (block.type === 'text' || block.type === 'reasoning') { + return tokens + densityPrice(block.text) + BLOCK_OVERHEAD + } + if (block.type === 'tool-call') { + return tokens + densityPrice(block.name) + densityPrice(block.arguments) + BLOCK_OVERHEAD + } + // ContentBlockMap is merge-extensible: this client graph sees only the + // base four members, but fixture turns do carry extended blocks at + // runtime, so the structural JSON fallback below is live code. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above). + if (block.type === 'tool-result') { + return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD + } + return tokens + densityPrice(JSON.stringify(block)) + BLOCK_OVERHEAD + }, 0) +} + +/** Fixture parallel of token-meter's heuristic context-composition projection. */ +function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection { + const headerEvent = log.findLast(event => event.type === 'request/header') + const header = headerEvent === undefined + ? undefined + : headerEvent.data.header + let messageTokens = 0 + for (const seq of foldSurface(log).nodes) { + const event = log[seq] + if (event === undefined) continue + const message = deriveEventMessage(event) + if (message !== null) messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD + } + return { + systemTokens: header?.system === undefined + ? 0 + : Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD, + toolsTokens: header?.tools === undefined || header.tools.length === 0 + ? 0 + : Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD, + messageTokens, + } +} + /** Latest log-only route context, or undefined before any request ran. */ function lastRequestContext( log: readonly SessionEvent[], @@ -835,7 +894,11 @@ function lastRequestContext( /** * Fixture parallel of token-meter's request-pressure projection: the last * provider-reported prompt size paired with the last recorded capacity. The - * two need not come from one request — see the token-meter README. + * two need not come from one request — see the token-meter README. The host's + * `projectedTokens` is deliberately absent: reproducing it would mean + * reimplementing the estimator client-side, and every consumer falls back to + * the bare sample, so a fixture-driven view simply lags a compaction the way + * the projection did before that field existed. */ function contextPressureOf( log: readonly SessionEvent[], @@ -873,41 +936,53 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow values['tokenUsage'] = tokenUsageOf(log) // Always present (token-meter composed): last request pressure and capacity. values['contextPressure'] = contextPressureOf(log) + // Always present (token-meter composed): heuristic request composition. + values['contextBreakdown'] = contextBreakdownOf(log) return values } /** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] { const type = (event as { type: string }).type + const frames: Extract<MuxFrame, { type: 'session/projection' }>[] = [] // One usage sample advances both token-meter units. if (usageSampleOf(event) !== undefined) { - return [ + 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 }, - ] + ) } if (type === 'request/context') { - return [{ + frames.push({ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq, - }] + }) } + if (type === 'request/header' + || type === 'user/message' + || type === 'assistant/message' + || type === 'tool/result') { + frames.push({ + type: 'session/projection', + sessionId: id, + key: 'contextBreakdown', + value: contextBreakdownOf(log), + seq: event.seq, + }) + } + if (frames.length > 0) return frames if (type === 'session/title') { 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 }] } - // Goal fold: a round-zero goal-sourced user message advances the goal unit. - if (type === 'user/message') { - const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source - if (source?.kind === 'goal' && source.round === 0) { - return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] - } - return [] + // 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 }] } // Standing-plan fold: writes replace the list; turn/start clears it (null). if (type === 'todo/write' || type === 'turn/start') { @@ -961,7 +1036,7 @@ function pageOf( const event = log[i] /* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */ if (event === undefined) break - if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++ + if (event.type === 'user/message' || event.type === 'assistant/message') messages++ if (event.type === 'turn/start' && messages >= maxMessages) { start = i break @@ -990,11 +1065,11 @@ function searchBlockText(block: ContentBlock): string[] { } } -/** One current-surface user/assistant/steering document, if searchable. */ +/** One current-surface user/assistant document, if searchable. */ function searchEventText(event: SessionEvent): string { const content = event.type === 'user/message' ? event.data.content - : event.type === 'assistant/message' || event.type === 'steering/message' + : event.type === 'assistant/message' ? event.data.message.content : undefined if (content === undefined) return '' @@ -1140,7 +1215,7 @@ interface FxGoalProjection { updatedAt: number } -/** One durable goal change riding a round-zero goal-sourced user message. */ +/** One durable goal change. */ type FxGoalChange = | { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number } | { @@ -1161,14 +1236,10 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { for (let i = log.length - 1; i >= 0; i--) { const event = log[i] as unknown as { type: string - data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } } + data?: FxGoalChange } | undefined - if (event === undefined || event.type !== 'user/message') continue - const source = event.data?.source - if (source?.kind !== 'goal' || source.round !== 0) continue - const change = source.change - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (change === undefined || change.kind !== 'goal/change') continue + if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue + const change = event.data if (change.operation === 'clear') return null return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } } @@ -1419,20 +1490,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) } - /** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */ + /** Append one durable goal/change (host GoalService parallel). */ const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => { - const ref = change.operation === 'clear' ? change.cleared : change.goal - const payload = change.operation === 'clear' - ? { cleared: change.cleared, clearedAt: change.clearedAt } - : { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } + const log = logOf(id) append(id, { - type: 'user/message', surfaceOp: 'append', - data: userMessage( - text(`<goal_state>${JSON.stringify(payload)}</goal_state>`), - { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource, - ), + type: 'goal/change', + data: change, }) - return backscanGoal(logOf(id)) as FxGoalProjection + return backscanGoal(log) as FxGoalProjection } /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ @@ -1583,23 +1648,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { nextTurn.set(sessionId, turn + 1) retryScenarios.set(sessionId, { turn, stepStarted: true }) setRunning(sessionId, true) - append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(sessionId, { type: 'turn/start', data: { turn } }) append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } }) append(sessionId, { type: 'step/start', data: { turn, step: 1 } }) append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } }) - append(sessionId, { type: 'step/end', data: { turn, step: 1 } }) }, - /** Record one retry decision, then open the next retry turn. */ + /** Record one retry decision; the next attempt remains in the same step. */ scheduleModelRetry(id: string, retry = 1, delayMs = 450): void { const sessionId = sid(id) const scenario = retryScenarios.get(sessionId) if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) if (!scenario.stepStarted) { - append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } }) append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } }) - append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) scenario.stepStarted = true } const failure = { code: 'TRANSPORT', message: '连接被重置' } @@ -1611,14 +1673,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { retry, maxRetries: 2, delayMs, failure, }, }) - append(sessionId, { - type: 'turn/end', - data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } }, - }) - const next = nextTurn.get(sessionId) ?? scenario.turn + 1 - nextTurn.set(sessionId, next + 1) - append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } }) - scenario.turn = next scenario.stepStarted = false }, /** Record one retry decision, then cancel its source turn before the retry starts. */ @@ -1635,17 +1689,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { retry: 1, maxRetries: 2, delayMs, failure, }, }) - append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted', reason: { kind: 'user' } }, + } }) retryScenarios.delete(sessionId) setRunning(sessionId, false) }, - /** Finish the timing-hook retry with a finalized response in the open retry turn. */ + /** Finish the timing-hook retry with a finalized response in the open step. */ completeModelRetry(id: string): void { const sessionId = sid(id) const scenario = retryScenarios.get(sessionId) if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) retryScenarios.delete(sessionId) - append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } }) + append(sessionId, { type: 'assistant/chunk', data: { + turn: scenario.turn, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'text' }, + } }) append(sessionId, { type: 'assistant/message', surfaceOp: 'append', @@ -1944,17 +2004,15 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { summary.blank = false const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') if (mode === 'steer' && replays.has(id)) { - // Steering: insert a steering message into the current turn; the replay continues. - /* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */ - const turn = (nextTurn.get(id) ?? 1) - 1 - append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } }) + // Steering: the durable user/message lands inside the current turn; the replay continues. + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) }) 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, trigger: { kind: 'message', source: { kind: 'user' } } } }) - // Boundary flush parallel (the host's agent/step seam): an outstanding + 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) { @@ -2390,6 +2448,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // editor; real schema-driven forms ride the HTTP transport. describe: request => ok(request, { writable: true, + hasDocument: true, namespaces: [{ ns: 'llm-deepseek', schema: {}, @@ -2399,6 +2458,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { revision: 0, }], }), + // Native opens are deterministic no-op successes in this fixture, as is host.openPath. + openDocument: request => ok(request, { opened: true as const }), update: request => err(request, { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', @@ -2549,6 +2610,7 @@ export class FixtureApiClient extends AbstractApiClient { 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) case 'settings.replace': return this.api.settings.replace(request) case 'settings.mutate': return this.api.settings.mutate(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 9e33e63ce2..daa4fb4036 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -18,7 +18,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, + MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index 9ae6eeae7d..a2c2d95b7b 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,12 +1,91 @@ -// WebApiClient: the browser platform subclass — transport = global fetch over same-origin -// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the -// base batching aspect; subscribers attach via subscribeEnvelopes (see boot). +/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +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' -/** Browser platform subclass: transport = global fetch over same-origin /api/*. */ +type SocketItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } +type Parser<F> = { parse(value: unknown): F } + +/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise<Response> { return globalThis.fetch(input, init) } + + protected override openMux( + _payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable<RpcRequest<MuxFrame>> { + return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) + } + + protected override openHost( + _payload: Parameters<ApiProxy['events']['host']>[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable<RpcRequest<HostFrame>> { + return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) + } + + private async *readWebSocket<F extends MuxFrame | HostFrame>( + path: string, + signal: AbortSignal, + frameSchema: Parser<F>, + onOpen?: () => void, + ): AsyncGenerator<RpcRequest<F>> { + const url = new URL(path, this.resolveBase()) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket(url) + const inbox: SocketItem<F>[] = [] + let wake: (() => void) | undefined + const enqueue = (item: SocketItem<F>): 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<F> + if (item.kind === 'end') return + yield item.envelope + } + await new Promise<void>((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/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..888675e965 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -2,13 +2,14 @@ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. -import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH } from './api-path.ts' +import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' -export { API_PATH } from './api-path.ts' +export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -52,6 +53,7 @@ const PRIVILEGED_METHODS = new Set([ 'host.pickDirectory', 'host.openPath', 'settings.describe', + 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', @@ -76,6 +78,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) + const downlinks = new WebSocketDownlinks(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, @@ -92,8 +95,31 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { res.end('forbidden') return } + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } await bridge(req, res, apiHandler) }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + ctx.effect(() => ctx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + ctx.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) }) } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts new file mode 100644 index 0000000000..72ae5e94ef --- /dev/null +++ b/packages/client/connection/src/websocket-downlink.ts @@ -0,0 +1,153 @@ +/** 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<Frame>): ServerRequest { + return { + type: 'server-request', + rpcId: frame.rpcId, + method: frame.payload.type, + payload: frame.payload, + } +} + +function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> { + 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<Frame> { + 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<Promise<void>>() + + /** @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<void> { + for (const socket of this.server.clients) socket.terminate() + await new Promise<void>((resolve, reject) => { + this.server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + await Promise.all(this.pumps) + } + + private upgrade<F extends Frame>( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>, + ): 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<F extends Frame>( + socket: WebSocket, + frames: AsyncIterable<RpcRequest<F>>, + abort: AbortController, + ): Promise<void> { + 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/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 43c71dffb7..524983fb4f 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -3,15 +3,55 @@ * selection off the page URL, and the single-consumer stream-loop ownership. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +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 { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string } } +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 })) + } +} afterEach(() => { delete (globalThis as Win).location + sockets.length = 0 + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket }) async function mount(): Promise<ConnectionHandle> { @@ -53,7 +93,7 @@ describe('connection client apply', () => { loop.stop() // teardown must not throw; the fixture streams abort quietly }) - it('WebApiClient carries requests over globalThis.fetch', async () => { + it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -65,9 +105,102 @@ 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/'))).toBe(true) + 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/commands-changed', + payload: { type: 'host/commands-changed' }, + })) + 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/commands-changed' } }, + }) + 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) }) }) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 88708f0625..6c7acbf9f7 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -181,7 +181,8 @@ export class FakeApiClient implements IApiClient { } readonly settings: IApiClient['settings'] = { - describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + 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 }))), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 4fc48d1ea4..3bdd0a21ea 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -159,6 +159,11 @@ describe('createFixtureApi', () => { }, // No request ran, so neither pressure nor capacity is known yet. contextPressure: {}, + contextBreakdown: { + systemTokens: 0, + toolsTokens: 0, + messageTokens: 0, + }, } }, }) }) @@ -304,6 +309,10 @@ describe('createFixtureApi', () => { frame.type === 'session/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<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message') expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)') // Idle cancel: no replay in flight, must not explode; running flips false. @@ -311,7 +320,7 @@ describe('createFixtureApi', () => { expect(idleCancel.result).toMatchObject({ ok: true }) }) - it('steer during a replay inserts a steering message and the replay continues to completion', async () => { + it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => { const api = createFixtureApi() const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') @@ -324,7 +333,7 @@ describe('createFixtureApi', () => { 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<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type) - expect(types).toContain('steering/message') + expect(JSON.stringify(frames)).toContain('插话') expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn }) @@ -335,7 +344,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest<MuxFrame>[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 10) abort.abort() + if (envelopes.length >= 11) abort.abort() } return envelopes } @@ -351,10 +360,15 @@ describe('createFixtureApi', () => { 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: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[9]?.rpcId).toBe(first[9]?.rpcId) + expect(first[8]?.payload).toMatchObject({ + type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown', + value: { systemTokens: 0, toolsTokens: 0 }, + }) + expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) + expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[10]?.rpcId).toBe(first[10]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -372,7 +386,7 @@ describe('createFixtureApi', () => { })) const frames = await framesPromise const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type) - expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert + 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 () => { @@ -1008,6 +1022,21 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { // 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 }) + if (!goalHistory.result.ok) throw new Error('goal history failed') + const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as { + type: string + data: { + operation?: string + source?: { kind?: string; round?: number } + } + }) + const goalChanges = goalEvents.filter(event => event.type === 'goal/change') + expect(goalChanges.map(event => event.data.operation)) + .toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear']) + expect(goalEvents.some(event => event.type === 'user/message' + && event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..d3ac13716e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,22 +1,29 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { Readable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject } from '../src/index.ts' +import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' -/** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { +/** Structural httpServer fake recording both route registries. */ +function fakeHttpServer( + routes: WebRoute[], + upgrades: WebUpgradeRoute[], +): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, + registerUpgrade(route) { + upgrades.push(route) + return () => { upgrades.splice(upgrades.indexOf(route), 1) } + }, tapIndex: () => () => {}, port: 0, } @@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ + routes: WebRoute[] + upgrades: WebUpgradeRoute[] + dispose: () => Promise<void> +}> { const ctx = new Context() const routes: WebRoute[] = [] - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const upgrades: WebUpgradeRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, dispose: () => fiber.dispose() } + return { routes, upgrades, dispose: () => fiber.dispose() } } describe('connection node half', () => { it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] + const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) + expect(upgrades).toHaveLength(0) }) - it('registers the /api prefix route and removes it with the fiber', async () => { - const { routes, dispose } = await mounted() + it('registers one HTTP route plus one upgrade route per downlink and removes all three 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]) 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 () => { @@ -93,7 +134,7 @@ describe('connection node half', () => { // passed), but each privileged method stays loopback-only and 403s. for (const method of [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', ]) { const denied = fakeResponse() @@ -177,7 +218,7 @@ describe('connection node half over a real HTTP server', () => { // Reads are as privileged as writes: describe returns the exposed // configuration, and credentials.describe probes arbitrary env-var names. for (const method of [ - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', ]) { diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts new file mode 100644 index 0000000000..9d53a82820 --- /dev/null +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -0,0 +1,308 @@ +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<RpcRequest<MuxFrame>> +type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>> + +const running: (() => Promise<void>)[] = [] + +afterEach(async () => { + await Promise.all(running.splice(0).map(close => close())) +}) + +function untilAbort(signal: AbortSignal): Promise<void> { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> { + 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<void> +}> { + 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<void>(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<void>(resolve => server.close(() => { resolve() })) + }, + } +} + +function read(socket: WebSocket): Promise<ServerRequest> { + return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) +} + +async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> { + const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).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/commands-changed' } } + 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/commands-changed', + payload: { type: 'host/commands-changed' }, + }) + + 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<void>((resolve) => { release = resolve }) + let finish!: () => void + const finished = new Promise<void>((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<void>((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<void>((resolve) => { cleanupStarted = resolve }) + let releaseCleanup!: () => void + const cleanupGate = new Promise<void>((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/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index ce015be85f..86859e79f6 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: f91a6c6f685c88a1ea19312985ad3e933222192a -README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a +README.md: 454c03cc3cd11722943efd025d164d9ca8233d25 +README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index f91a6c6f68..454c03cc3c 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -17,5 +17,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. -- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows. -- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism. +- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically. +- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary. diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 1d20a22d21..fc4100c48e 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -17,5 +17,5 @@ ## 已知限制与暂缓事项 - **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。 -- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。 -- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。 +- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。 +- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新。 diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 99f4d7a7fe..7f86a68b10 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -49,8 +49,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 0f4dd20268..d1ef53207f 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce -README.zh.md: 62c037977115d33b834fe60b042431e44d208524 +README.md: f1efefde4557e1c29c0556f8b670f1534430ab79 +README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 7f780092af..f1efefde45 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs. +- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly. - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 62c0379771..a8b5704d28 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -10,9 +10,9 @@ locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 ` #### KV Cache 影响 -无;该包(package)既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供方请求。 ## 已知限制与暂缓事项 -- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。 +- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。 - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 4d75496dc8..75742a80d0 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -51,9 +51,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index c5e54c5be9..5d195ee275 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -11,7 +11,6 @@ * string-typed. The rule fires on the narrow-map view, not real redundancy. */ import type { Context } from 'cordis' import { - deferRegistration, type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -384,16 +383,12 @@ export function apply(ctx: ClientContext): void { setLocale: (id) => { locale.setLocale(id) }, } } - ctx.effect(() => { - const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'language', - order: 0, - store, - locale: SETTINGS_NS, - inject: injected, - }, LanguageRow)) - return () => { deferred.dispose() } - }, 'locale: language settings row registration') + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'language', + order: 0, + store, + locale: SETTINGS_NS, + inject: injected, + }, LanguageRow)) } diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 468ffdf0bc..2283853a7d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -42,9 +42,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..23c867e4c0 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 +README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..8ac29a4258 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,12 +2,20 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. + +## Slot declaration injection + +`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. + +The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md). ## Workspace and Session lists Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. + `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. `WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. @@ -18,15 +26,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## New Session and the blank mirror -`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its `InboxItemId`, stable `MessageId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection, while an accepted live `steering/message` event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same `MessageId`. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`. +`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. @@ -54,10 +62,6 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. -## Addressed subagent conversations - -`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent. - ## Model Experience None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content. @@ -68,6 +72,6 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## Known Limitations and Deferred Work -- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. +- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal. - **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..0e065e43ec 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,12 +2,20 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 + +## Slot 声明注入 + +`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 + +回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。 ## Workspace 与 Session 列表 Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 + `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 `WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 @@ -18,15 +26,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## New Session 与 blank 镜像 -`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照,携带 queued 与待处理 steering(中途引导)单次入队项及其已解析 placement。每行都携带其 `InboxItemId`、稳定的 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;已接纳的实时 `steering/message` 事件则只退役第一个匹配的当前 steering 单次入队项,让持久节点能在下一份 Host 快照之前接管,而历史回放绝不会消费后来复用同一 `MessageId` 的单次入队项。重连缓冲只保留最新快照,普通持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。 +`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 @@ -44,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 模型重试投影 -Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。 +Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。 ## 会话 fork @@ -54,10 +62,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验 每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。 -## 已寻址的 subagent 对话 - -`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;Host 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。 - ## 模型体验 无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。 @@ -68,6 +72,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验 ## 已知限制与暂缓事项 -- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。 +- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。 - **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index f9cc6a308c..b636316b68 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -59,8 +59,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts index 2b6679fd05..a48e89585e 100644 --- a/packages/client/runtime/src/client/contract/session-history.ts +++ b/packages/client/runtime/src/client/contract/session-history.ts @@ -9,6 +9,8 @@ export interface SessionHistorySnapshot { state: 'cold' | 'loading' | 'ready' | 'error' error: RpcError | null hasMore: boolean + /** Absolute sequence of the first loaded raw event, or zero for an empty window. */ + baseSeq: number inspection: SessionHistoryInspection } @@ -17,11 +19,17 @@ export interface SessionHistoryFace extends ObservableSnapshot<SessionHistorySnapshot> { readonly sessionId: SessionId /** - * Load the tail and exhaust every available older page. - * @param signal - Consumer lifetime; abort is observed between page requests. - * @returns When the available ledger is complete or stops advancing. + * Load the current tail without reading older pages. + * @param signal - Consumer lifetime. + * @returns When the tail is ready or loading fails. */ - loadAll(signal?: AbortSignal): Promise<void> + loadTail(signal?: AbortSignal): Promise<void> + /** + * Prepend one older page when the current window has a predecessor. + * @param signal - Consumer lifetime. + * @returns Whether the loaded window advanced. + */ + loadOlder(signal?: AbortSignal): Promise<boolean> } /** Runtime service resolving independent history sources. */ diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index a68fececa3..0e21e6ae06 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -9,7 +9,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { - InboxItemId, QueueAction, RpcResult, SessionId, + MessageId, QueueAction, RpcResult, SessionId, } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -44,7 +44,7 @@ export interface ISession { * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ - updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> + updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> /** * Cancel the running turn. Pending queued work remains and resumes in FIFO * order after the Host reaches cancellation quiescence. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0a384fffa2..06f88a9131 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,13 +53,18 @@ export type { export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' +export type { + ContextProvenanceView, ContextRole, KnownContextForm, +} from './sessions/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' export type { ConversationHistoryProjection } from './session-history/history-fold.ts' export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' -export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' +export type { + PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, +} from './sessions/pending.ts' // Projection value store (session-projection RFC, push model): host-computed // whole values per key; domains ship projection support with zero client code. export type { diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..d792fd2b76 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -11,11 +11,15 @@ import type { PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' +import { contextForm, contextProvenance } from '../sessions/context-provenance.ts' +import { SteeringHistory } from '../sessions/steering-history.ts' import type { ConversationContext, ConversationContextOriginKind, } from '../sessions/conversation-context.ts' import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' import { PartialAccumulator } from '../sessions/partial.ts' +import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' +import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' interface CallIndexEntry { name: string @@ -30,11 +34,6 @@ interface FoldedContext { originSeq?: number } -interface AssistantStepMetadata { - stepStartTime: number | null - firstTokenTime: number | null -} - /** Immutable conversation projections derived only from the history source. */ export interface ConversationHistoryProjection { eventNodes: readonly ConversationNode[] @@ -45,22 +44,10 @@ export interface ConversationHistoryProjection { codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> } -function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -// Trajectory owns surface-window reconstruction so its immutable ledger does -// not depend on Chat's live fold adapter or Session's mutable state. -/* jscpd:ignore-start */ -function paddingEvent(seq: number): SessionEvent { - return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent -} - function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq } -/* jscpd:ignore-end */ function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { if (event?.type !== 'user/message') return 'rewrite' @@ -72,39 +59,61 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext return 'rewrite' } -function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): 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 - } -} - function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { const replay: SessionEvent[] = [] + const originalSeqs: number[] = [] + const rebasedSeqByOriginal = new Map<number, number>() const surface = new SurfaceManager(replay) const contexts: FoldedContext[] = [] let generation = 0 let originSeq: number | undefined + const originalNodes = () => surface.nodes.map((seq) => { + const original = originalSeqs[seq] + if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`) + return original + }) for (const event of events) { - if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { + if (!isSurfaceEvent(event)) continue + if (event.surfaceOp !== 'append') { contexts.push({ generation, - nodes: [...surface.nodes], + nodes: originalNodes(), ...(originSeq === undefined ? {} : { originSeq }), }) generation++ originSeq = event.seq } - replay.push(event) + const rebasedSeq = replay.length + const { + sourceEventSeqs: rawSources, + ...eventWithoutSources + } = event as SessionEvent & { sourceEventSeqs?: readonly number[] } + const mappedSourceEventSeqs = rawSources?.flatMap((seq) => { + const rebased = rebasedSeqByOriginal.get(seq) + return rebased === undefined ? [] : [rebased] + }) + const sourceEventSeqs = mappedSourceEventSeqs?.length === 0 + ? undefined + : mappedSourceEventSeqs + const surfaceOp = event.surfaceOp === 'append' + ? event.surfaceOp + : { + ...event.surfaceOp, + start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start, + end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end, + } + originalSeqs.push(event.seq) + rebasedSeqByOriginal.set(event.seq, rebasedSeq) + replay.push({ + ...eventWithoutSources, + seq: rebasedSeq, + surfaceOp, + ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }), + } as SessionEvent) } contexts.push({ generation, - nodes: [...surface.nodes], + nodes: originalNodes(), ...(originSeq === undefined ? {} : { originSeq }), }) return contexts @@ -119,6 +128,7 @@ function materializeNode( resultView: ToolResultView | null, assistantTiming: AssistantTiming | undefined, requestConfig: AssistantRequestConfig | undefined, + steering: boolean, ): ConversationNode { switch (event.type) { case 'user/message': @@ -126,6 +136,15 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + if (steering) { + return { + kind: 'steering', messageId: event.data.id, + seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, } } return { @@ -144,12 +163,6 @@ function materializeNode( ...(requestConfig === undefined ? {} : { requestConfig }), ...(assistantTiming === undefined ? {} : { timing: assistantTiming }), } - case 'steering/message': - return { - kind: 'steering', messageId: event.data.message.id, - seq: event.seq, time: event.time, turn: event.data.turn, - content: event.data.message.content, source: event.data.message.source, - } case 'tool/result': { const result = event.data.message.content[0] const callId = String(event.data.message.source.callId) @@ -331,11 +344,13 @@ export function projectConversationHistory( entries: readonly HistoryEntry[], ): ConversationHistoryProjection { const events = entries.map(entry => entry.event) + const steeringHistory = new SteeringHistory() + const steeringSeqs = new Set<number>() + for (const event of events) { + if (steeringHistory.apply(event)) steeringSeqs.add(event.seq) + } const baseSeq = events[0]?.seq ?? 0 - const padded = [ - ...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)), - ...events, - ] + const eventsBySeq = new Map(events.map(event => [event.seq, event])) const callIndex = new Map<string, CallIndexEntry>() const resultViews = new Map<number, ToolResultView>() const assistantSteps = new Map<string, AssistantStepMetadata>() @@ -362,6 +377,7 @@ export function projectConversationHistory( contextGeneration++ if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) } + indexAssistantStepTiming(assistantSteps, event) if (event.type === 'request/header') { activeRequestConfig = event.data.header.config activePrompt = { @@ -370,30 +386,10 @@ export function projectConversationHistory( tools: event.data.header.tools ?? [], } promptsByContext.set(contextGeneration, activePrompt) - } else if (event.type === 'step/start') { - assistantSteps.set( - assistantStepKey(event.data.turn, event.data.step), - { stepStartTime: event.time, firstTokenTime: null }, - ) - } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { - const key = assistantStepKey(event.data.turn, event.data.step) - const current = assistantSteps.get(key) ?? { - stepStartTime: null, - firstTokenTime: null, - } - if (current.firstTokenTime === null) { - assistantSteps.set(key, { ...current, firstTokenTime: event.time }) - } } else if (event.type === 'assistant/message') { assistantTimings.set( event.seq, - { - ...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? { - stepStartTime: null, - firstTokenTime: null, - }), - completedTime: event.time, - }, + settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time), ) if (activeRequestConfig !== undefined) { assistantRequestConfigs.set(event.seq, activeRequestConfig) @@ -405,7 +401,7 @@ export function projectConversationHistory( const materialize = (seq: number): ConversationNode | undefined => { const cached = nodeCache.get(seq) if (cached !== undefined) return cached - const event = padded[seq] + const event = eventsBySeq.get(seq) if (event === undefined || !isSurfaceEligibleType(event.type)) return const node = materializeNode( event, @@ -413,6 +409,7 @@ export function projectConversationHistory( resultViews.get(seq) ?? null, assistantTimings.get(seq), assistantRequestConfigs.get(seq), + steeringSeqs.has(seq), ) nodeCache.set(seq, node) return node @@ -431,7 +428,7 @@ export function projectConversationHistory( }] } else { try { - contexts = foldContexts(padded).map((context): ConversationContext => { + contexts = foldContexts(events).map((context): ConversationContext => { const nodes = context.nodes.flatMap((seq) => { const node = materialize(seq) return node === undefined ? [] : [node] @@ -444,7 +441,7 @@ export function projectConversationHistory( nodes, } } - const originEvent = padded[context.originSeq] + const originEvent = eventsBySeq.get(context.originSeq) return { id: context.generation, parentId: context.generation - 1, diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 8b9e6b5093..44e760b2b2 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -6,7 +6,9 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionHistoryFace, SessionHistorySnapshot, } from '../contract/session-history.ts' -import { createHistoryInspection } from '../sessions/history.ts' +import { + compactHistoryInspectionEntries, createHistoryInspection, +} from '../sessions/history.ts' import { Notifier } from '../sessions/notifier.ts' import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts' @@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean { /** Independent raw-history owner used only by inspection consumers. */ export class SessionHistorySource implements SessionHistoryFace { - private entries: readonly HistoryEntry[] = [] + private entries: HistoryEntry[] = [] + private inspectionEntries: readonly HistoryEntry[] = [] private baseSeq = 0 private hasMore = false private state: SessionHistorySnapshot['state'] = 'cold' @@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace { value: SessionHistorySnapshot['inspection'] } | null = null private streamPublishToken: object | null = null - private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null private streamPartial: PartialAccumulator | null = null private snapshotCache: SessionHistorySnapshot private readonly notifier = new Notifier(() => { @@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace { } /** - * Load the tail and exhaust all available older pages. + * Load the current tail without reading older pages. * @param signal - Consumer lifetime. - * @returns When paging completes, fails to advance, or is aborted. + * @returns When the tail is ready or loading fails. */ - async loadAll(signal?: AbortSignal): Promise<void> { - if (signal?.aborted === true) return + async loadTail(signal?: AbortSignal): Promise<void> { + if (isAborted(signal)) return this.trackConsumer(signal) await this.open() - while ( - !isAborted(signal) - && this.state === 'ready' - && this.hasMore - ) { - const previousBaseSeq = this.baseSeq - await this.loadOlder() - if (isAborted(signal) || this.baseSeq === previousBaseSeq) return - } } - /** Rebuild and page for whichever mounted consumers survive a reconnect. */ - private async loadForConsumers(): Promise<void> { + /** + * Prepend one older page when the current window has a predecessor. + * @param signal - Consumer lifetime. + * @returns Whether the loaded window advanced. + */ + async loadOlder(signal?: AbortSignal): Promise<boolean> { + if (isAborted(signal)) return false + this.trackConsumer(signal) await this.open() - while ( - this.hasConsumer() - && this.state === 'ready' - && this.hasMore - ) { - const previousBaseSeq = this.baseSeq - await this.loadOlder() - if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return - } + if (isAborted(signal)) return false + const previousBaseSeq = this.baseSeq + await this.loadOlderPage() + return this.baseSeq !== previousBaseSeq } /** @@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace { this.liveBuffer = [] this.subscribedLastSeq = null this.entries = [] + this.inspectionEntries = [] this.baseSeq = 0 this.hasMore = false this.state = 'cold' this.error = null this.publishDirtyNow() - void this.loadForConsumers() + void this.open() } /** Stop future refresh work after the host removes the session. */ @@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace { this.olderPromise = null this.liveBuffer = [] this.streamPublishToken = null - this.streamBaseInspection = null this.streamPartial = null } @@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace { } } - private loadOlder(): Promise<void> { + private loadOlderPage(): Promise<void> { if (this.olderPromise !== null) return this.olderPromise if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() const generation = this.generation @@ -260,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace { return } this.entries = [...older, ...this.entries] + this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore } catch (error) { @@ -291,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.entries = [...prefix, ...tail] } this.baseSeq = this.entries[0]?.event.seq ?? 0 + this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) const buffered = this.liveBuffer this.liveBuffer = [] for (const entry of buffered) this.appendLive(entry) @@ -324,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace { private appendLive(entry: HistoryEntry): void { const tailSeq = this.tailSeq() if (tailSeq !== null && entry.event.seq <= tailSeq) return - this.entries = [...this.entries, entry] + this.entries.push(entry) + this.inspectionEntries = [...this.inspectionEntries, entry] + if (entry.event.type === 'assistant/message') { + this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries) + } } /** Append a chunk against the cached finalized projection; false means no visible publish. */ @@ -336,11 +336,10 @@ export class SessionHistorySource implements SessionHistoryFace { if (!isVisibleAssistantChunk(chunk.type)) { const inspection = this.currentInspection() this.appendLive(entry) - this.inspectionCache = { entries: this.entries, value: inspection } + this.inspectionCache = { entries: this.inspectionEntries, value: inspection } return false } - const base = this.streamBaseInspection ?? this.currentInspection() - this.streamBaseInspection = base + const base = this.currentInspection() if ( this.streamPartial === null || this.streamPartial.turn !== turn @@ -356,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.streamPartial.push(chunk) this.appendLive(entry) this.inspectionCache = { - entries: this.entries, + entries: this.inspectionEntries, value: { ...base, partial: this.streamPartial.toPartial() }, } return true @@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace { /** Publish structural changes immediately and invalidate an older scheduled stream publish. */ private publishDirtyNow(): void { this.streamPublishToken = null - this.streamBaseInspection = null this.streamPartial = null this.notifier.markDirty() } @@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace { state: this.state, error: this.error, hasMore: this.hasMore, + baseSeq: this.baseSeq, inspection: this.currentInspection(), } } /** Inspection pinned to the source's current immutable entry array. */ private currentInspection(): SessionHistorySnapshot['inspection'] { - if (this.inspectionCache?.entries !== this.entries) { - const entries = this.entries + if (this.inspectionCache?.entries !== this.inspectionEntries) { + const entries = this.inspectionEntries this.inspectionCache = { entries, value: createHistoryInspection(() => entries), diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts new file mode 100644 index 0000000000..021c679d04 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -0,0 +1,84 @@ +// Shared assistant step-timing fold: both transcript projections (the live +// window adapter and the trajectory history fold) derive AssistantTiming from +// the same step/start -> first token delta -> assistant/message sequence, so +// the derivation lives once here instead of drifting per projection. + +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { AssistantTiming } from './conversation.ts' + +/** Pre-finalize timing boundaries for one assistant step (start + first token). */ +export interface AssistantStepMetadata { + stepStartTime: number | null + firstTokenTime: number | null +} + +/** + * Composite map key for one assistant step. + * @param turn - turn number from the event payload. + * @param step - step number from the event payload. + * @returns collision-free `turn`/`step` key (NUL separator). + */ +export function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +/** + * Whether a chunk carries visible model output (first-token boundary). Empty + * deltas (heartbeats, empty tool-call frames) do not count as a first token. + * @param chunk - the assistant/chunk payload. + * @returns true when the chunk contains a non-empty text/reasoning/tool delta. + */ +export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): 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 + } +} + +/** + * Fold one event into the per-step timing index: step/start opens the entry, + * the first non-empty token delta stamps first-token time once. Other event + * types are no-ops. + * @param steps - the mutable per-step index, keyed by {@link assistantStepKey}. + * @param event - the raw window event. + */ +export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void { + if (event.type === 'step/start') { + steps.set( + assistantStepKey(event.data.turn, event.data.step), + { stepStartTime: event.time, firstTokenTime: null }, + ) + } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { + const key = assistantStepKey(event.data.turn, event.data.step) + const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null } + if (current.firstTokenTime === null) { + steps.set(key, { ...current, firstTokenTime: event.time }) + } + } +} + +/** + * Settle one finalized assistant message's timing from its step entry; a step + * whose start or first token fell outside the window yields null boundaries. + * @param steps - the per-step index built by {@link indexAssistantStepTiming}. + * @param turn - the assistant/message turn number. + * @param step - the assistant/message step number. + * @param completedTime - the assistant/message event timestamp (epoch ms). + * @returns the node-ready timing record. + */ +export function settledAssistantTiming( + steps: ReadonlyMap<string, AssistantStepMetadata>, + turn: number, + step: number, + completedTime: number, +): AssistantTiming { + return { + ...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }), + completedTime, + } +} diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts new file mode 100644 index 0000000000..5d231b6bd8 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -0,0 +1,116 @@ +// Context provenance projection: the role and the human-facing producer name +// of one logged non-user `user/message`, read from its durable `source` alone. +// The client keeps no table of known plugin ids — a renamed or newly mounted +// producer must never need a client release to stay identifiable, and a resumed +// or foreign log must project the same way as a live one. + +/** + * Which model-facing role a logged non-user message plays. + * + * `recall` marks material lifted out of another session's log; `inject` marks + * every other producer-supplied context. Mid-turn steering is the third role + * the transcript distinguishes, but it has its own event and node kind + * (`steering/message` / `SteeringMessageNode`) and never reaches here. + */ +export type ContextRole = 'inject' | 'recall' + +/** Role and producer name presented for one logged non-user message. */ +export interface ContextProvenanceView { + /** The role this context plays in the model-facing conversation. */ + role: ContextRole + /** + * Producer name for the row header, taken from the durable source: the + * instruction paths, the referenced session titles, the plugin id, or the + * bare source kind for a producer this UI version does not know. Null only + * when the source carries no readable kind at all. + */ + label: string | null +} + +/** One durable source narrowed to the readable-record shape; null for anything else. */ +function asRecord(value: unknown): Record<string, unknown> | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : null +} + +/** A record field read as a non-empty string, or null. */ +function readString(record: Record<string, unknown>, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */ +function collect(source: Record<string, unknown>, member: string, field: string): string[] { + const list = source[member] + if (!Array.isArray(list)) return [] + const seen: string[] = [] + for (const entry of list) { + const record = asRecord(entry) + const value = record === null ? null : readString(record, field) + if (value !== null && !seen.includes(value)) seen.push(value) + } + return seen +} + +/** A collected name list rendered as one label; null when the list is empty. */ +function joined(names: string[]): string | null { + return names.length > 0 ? names.join(', ') : null +} + +/** + * Project one durable message source onto its transcript role and producer name. + * + * The source arrives over the wire as opaque JSON (`MessageSource` is + * merge-extensible, so no client-side union can be exhaustive), and a durable + * log may predate or postdate this UI; every unreadable shape therefore + * degrades to `inject` with whatever name the record still carries. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the role and producer name to present for this context. + */ +export function contextProvenance(source: unknown): ContextProvenanceView { + const record = asRecord(source) + const kind = record === null ? null : readString(record, 'kind') + if (record === null || kind === null) return { role: 'inject', label: null } + switch (kind) { + // Cross-session snapshots are the one durable source that carries another + // session's material; its references name the sessions they were read from. + case 'session-reference': + return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } + // Workspace instructions name the files they were reconciled from, which + // identifies the producer far better than the plugin id would. + case 'workspace-instructions': + return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } + case 'plugin': + return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // Documented default arm of the merge-extensible source map: an unknown + // producer still identifies itself by its own durable kind. + default: + return { role: 'inject', label: kind } + } +} + +/** + * Context forms this UI version renders with a dedicated presentation. The + * durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an + * unrecognized or absent value degrades to the opaque presentation rather than + * dropping the row, so a log written by a newer or foreign producer still + * renders. + */ +const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const + +/** One durable context form this UI version knows how to present. */ +export type KnownContextForm = typeof KNOWN_FORMS[number] + +/** + * Read the producer-declared form off one durable message source. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the form when this UI version presents it, otherwise null (opaque). + */ +export function contextForm(source: unknown): KnownContextForm | null { + const record = asRecord(source) + const form = record === null ? null : readString(record, 'form') + return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) + ? form as KnownContextForm + : null +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index c4e195bc88..d24b963d6b 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -9,9 +9,10 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { - InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, + RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -102,15 +103,14 @@ export interface AssistantMessageNode { interrupted?: true } -/** A steering message injected mid-turn. */ +/** A human message admitted from the next-step inbox while a turn was running. */ export interface SteeringMessageNode { kind: 'steering' - /** Stable identity shared with its pre-admission inbox occurrence. */ + /** Stable message identity shared with its pre-admission inbox occurrence. */ messageId: MessageId seq: number /** Unix epoch ms from the source session event. */ time: number - turn: number content: readonly ContentBlock[] source: unknown } @@ -123,6 +123,10 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown + /** Role and producer name projected from `source` ({@link contextProvenance}). */ + provenance: ContextProvenanceView + /** Producer-declared information form ({@link contextForm}); null presents as opaque. */ + form: KnownContextForm | null } /** Durable notice that a closed failed step is waiting for a model-request retry. */ @@ -276,11 +280,11 @@ export interface RunningToolCall { /** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ export interface QueuedMessage { - readonly id: InboxItemId + readonly id: MessageId /** Stable message identity used for transient-to-durable steering handoff. */ readonly messageId: MessageId /** Agent-resolved placement; only queued rows accept queue mutations. */ - readonly placement: 'queued' | 'steering' + readonly placement: 'queued' | 'steering' | 'context' /** Complete content used to render pending steering before it becomes durable. */ readonly content: readonly ContentBlock[] readonly preview: string diff --git a/packages/client/runtime/src/client/sessions/failure-display.ts b/packages/client/runtime/src/client/sessions/failure-display.ts index 637329772b..88531f0857 100644 --- a/packages/client/runtime/src/client/sessions/failure-display.ts +++ b/packages/client/runtime/src/client/sessions/failure-display.ts @@ -1,10 +1,13 @@ /** * Convert a durable failure into copy that is safe to expose in the GUI. - * @param failure - Structured failure preserved by the session event. + * @param failure - Failure value preserved by the session event. * @returns Display-safe copy for client projections. */ -export function displayFailureMessage(failure: { code?: string; message: string }): string { +export function displayFailureMessage(failure: unknown): string { + if (failure === null || typeof failure !== 'object') return String(failure) + const record = failure as { code?: unknown; message?: unknown } // Provider AUTH messages may echo a masked or partially preserved credential. // Keep the raw diagnostic in the session log, but never project it into UI state. - return failure.code === 'AUTH' ? 'API key is invalid' : failure.message + if (record.code === 'AUTH') return 'API key is invalid' + return typeof record.message === 'string' ? record.message : JSON.stringify(failure) } diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts index 520503a561..fbf7b08f3d 100644 --- a/packages/client/runtime/src/client/sessions/history.ts +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts' import { projectConversationHistory } from '../session-history/history-fold.ts' import { inspectRequests, type RequestView } from './request-inspection.ts' +function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function isFirstTokenCandidate(entry: HistoryEntry): boolean { + const event = entry.event + if (event.type !== 'assistant/chunk') return false + switch (event.data.chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return event.data.chunk.text !== '' + case 'tool-call-delta': + return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined + default: + return false + } +} + /** Lazily derived inspection data for one immutable session-history window. */ export interface SessionHistoryInspection { eventNodes: readonly ConversationNode[] @@ -19,6 +37,47 @@ export interface SessionHistoryInspection { codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> } +/** + * Remove completed-step token payloads that no inspection projection reads. + * The first visible token preserves timing, usage chunks preserve accounting, + * and unfinished steps retain every chunk for live or interrupted content. + * @param entries - Contiguous raw history entries in sequence order. + * @returns A projection-equivalent, usually much smaller entry ledger. + */ +export function compactHistoryInspectionEntries( + entries: readonly HistoryEntry[], +): readonly HistoryEntry[] { + const completedSteps = new Set<string>() + for (const { event } of entries) { + if (event.type === 'assistant/message') { + completedSteps.add(assistantStepKey(event.data.turn, event.data.step)) + } + } + + const firstTokenSteps = new Set<string>() + const compacted: HistoryEntry[] = [] + let changed = false + for (const entry of entries) { + const event = entry.event + if (event.type !== 'assistant/chunk') { + compacted.push(entry) + continue + } + const key = assistantStepKey(event.data.turn, event.data.step) + if (!completedSteps.has(key) || event.data.chunk.type === 'usage') { + compacted.push(entry) + continue + } + if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) { + firstTokenSteps.add(key) + compacted.push(entry) + } else { + changed = true + } + } + return changed ? compacted : entries +} + /** * Create a lazy inspection projection over an immutable history window. * Conversation consumers retain the cheap wrapper; only Trajectory snapshots diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 4f26674420..115370488f 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -4,6 +4,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { PendingInteractionStatus } from './pending.ts' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { @@ -12,7 +13,7 @@ export interface TitledSessionSummary extends SessionSummary { projectionValues?: Readonly<Partial<SessionProjectionMap>> } -/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ +/** One flattened session-list row with lineage depth and live pending interaction. */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -26,8 +27,8 @@ export interface SessionListEntry { cwd?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly<Partial<SessionProjectionMap>> - /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ - waitingApproval: boolean + /** User interaction currently blocking this session, derived from live mux frames. */ + pendingInteraction?: PendingInteractionStatus /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -37,10 +38,13 @@ 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 waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). + * @param pendingInteractions - current manager-owned interaction status by session. * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] { +export function flattenLineage( + summaries: readonly TitledSessionSummary[], + pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>, +): SessionListEntry[] { const byId = new Map<SessionId, TitledSessionSummary>() for (const s of summaries) byId.set(s.sessionId, s) @@ -64,7 +68,12 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti return } visited.add(s.sessionId) - out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) + const pendingInteraction = pendingInteractions?.get(s.sessionId) + out.push({ + ...s, + ...(pendingInteraction === undefined ? {} : { pendingInteraction }), + depth, + }) const kids = children.get(s.sessionId) if (kids === undefined) return for (const kid of kids) walk(kid, depth + 1) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b4bf12a0e3..c9961592ba 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' +import type { PendingInteractionStatus } from './pending.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. @@ -70,23 +71,44 @@ type SessionListMutation = /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } -/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ -const PENDING_BUFFER_CAP = 32 +/** Stable identity of a frame retained until an uninstantiated Session can consume it. */ +function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined { + const frame = envelope.payload + switch (frame.type) { + case 'approval/requested': return `a:${frame.approvalId}` + case 'question/requested': return `q:${envelope.rpcId}` + case 'session/queue': return 'queue' + /* v8 ignore next -- pendingBuffers contains only the three frame types above. */ + default: return undefined + } +} +/** Match ui-question's binary plan-review routing at the wire boundary. */ +function questionInteractionStatus( + questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'], +): PendingInteractionStatus { + if (questions.length !== 1) return 'question' + const question = questions[0] as typeof questions[number] + const intent = question.intent + if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question' + if (question.multiSelect === true) return 'question' + const options = question.options ?? [] + if (options.length > 2) return 'question' + return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question' +} /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { private readonly sessions = new Map<SessionId, Session>() - /** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit - * history (cannot be backfilled on open), the one frame class that must not take the - * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these - * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ + /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history + * cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates + * compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */ private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>() - /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open - * replays of the same requested frame). Manager-owned rather than read off Session instances - * because the sidebar must light up for sessions never instantiated. Cleared per connection - * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ - private readonly waitingApprovals = new Map<SessionId, Set<string>>() + /** Outstanding answerable interactions per session, keyed by their stable request identity. + * Manager-owned rather than read off Session instances because the sidebar must light up for + * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds + * still-pending requests — and on session-removed. */ + private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>() /** 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 @@ -567,6 +589,26 @@ export class SessionManager { return this.listSnapshotCache } + /** Add or refresh one stable pending-interaction identity. */ + private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void { + let interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined) { + interactions = new Map() + this.pendingInteractions.set(sessionId, interactions) + } + if (interactions.get(key) === status) return + interactions.set(key, status) + this.notifier.markDirty() + } + + /** Settle one pending-interaction identity without disturbing sibling waits. */ + private resolvePending(sessionId: SessionId, key: string): void { + const interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined || !interactions.delete(key)) return + if (interactions.size === 0) this.pendingInteractions.delete(sessionId) + this.notifier.markDirty() + } + // ---- ConnectionController sinks (wired by boot) ---- /** @@ -592,11 +634,10 @@ export class SessionManager { // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) this.notifier.markDirty() - // New mux-generation baseline: buffered session/queue frames belong to - // the previous generation and the host is about to resend the live - // snapshot — drop them, or every reconnect appends a duplicate batch - // (and enough reconnects push real approval/question frames past the - // cap). Same re-baseline signal Session uses for its own mirror. + // New mux-generation baseline: discard the previous queue snapshot. + // The host omits session/queue when the live queue is empty, so retaining + // it could replay stale work when the Session is instantiated later. + // This is the same re-baseline signal Session uses for its own mirror. const buffered = this.pendingBuffers.get(frame.sessionId) if (buffered !== undefined) { const kept = buffered.filter(item => item.payload.type !== 'session/queue') @@ -606,43 +647,54 @@ export class SessionManager { } } } - // List-level waiting-approval bit (the sidebar amber dot): tracked here for - // every session, instantiated or not; approvalId keys make replays idempotent. + // List-level pending-interaction status (the sidebar amber dot): tracked + // for every session, instantiated or not; stable keys make replays idempotent. if (frame.type === 'approval/requested') { - let ids = this.waitingApprovals.get(frame.sessionId) - if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) - if (!ids.has(frame.approvalId)) { - ids.add(frame.approvalId) - this.notifier.markDirty() - } + this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval') } else if (frame.type === 'approval/resolved') { - const ids = this.waitingApprovals.get(frame.sessionId) - if (ids !== undefined && ids.delete(frame.approvalId)) { - if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) - this.notifier.markDirty() - } + this.resolvePending(frame.sessionId, `a:${frame.approvalId}`) + } else if (frame.type === 'question/requested') { + this.trackPending( + frame.sessionId, + `q:${envelope.rpcId}`, + questionInteractionStatus(frame.questions), + ) + } else if (frame.type === 'question/resolved') { + this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question/queue frames never hit history: buffer for replay on - // instantiation; everything else drops (not instantiated — history fully - // backfills on open). + // Answerable requests never hit history: retain each live identity until + // instantiation, compacting replay duplicates and resolutions so list + // status cannot outlive the PendingWait the user would need to answer. + // Queue is a latest-value snapshot; everything else drops because open + // backfills it from history. switch (frame.type) { case 'approval/requested': - case 'approval/resolved': case 'question/requested': - case 'question/resolved': case 'session/queue': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] - const prior = frame.type === 'session/queue' - ? buffer.findIndex(item => item.payload.type === 'session/queue') - : -1 - if (prior !== -1) buffer.splice(prior, 1) - buffer.push(envelope) - if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) + const key = frame.type === 'approval/requested' + ? `a:${frame.approvalId}` + : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue' + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior === -1) buffer.push(envelope) + else buffer[prior] = envelope this.pendingBuffers.set(frame.sessionId, buffer) return } + case 'approval/resolved': + case 'question/resolved': { + const buffer = this.pendingBuffers.get(frame.sessionId) + if (buffer === undefined) return + const key = frame.type === 'approval/resolved' + ? `a:${frame.approvalId}` + : `q:${frame.questionRpcId}` + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior !== -1) buffer.splice(prior, 1) + if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId) + return + } default: return } @@ -689,7 +741,7 @@ export class SessionManager { this.sessions.get(frame.sessionId)?.handleRemoved() } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone + this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -735,20 +787,19 @@ export class SessionManager { * The moment a connection generation dies (before any next-generation frame * can arrive — onConnected waits for the readiness handshake while replayed * frames flow from stream open, so clearing there would race the replay): - * drop generation-scoped live state. Approvals resolved while disconnected - * send no frame, so the stale bits and the buffered answerable frames must - * not survive into the next generation — the mux-open replay re-adds every - * still-pending question with its live rpcId. - */ + * drop generation-scoped live state. Interactions resolved while disconnected + * send no frame, so stale statuses and buffered answerable frames must not + * survive into the next generation — mux-open replay re-adds every still-pending + * request with its live rpcId. + */ handleDisconnected(): void { - if (this.waitingApprovals.size > 0) { - this.waitingApprovals.clear() + if (this.pendingInteractions.size > 0) { + this.pendingInteractions.clear() this.notifier.markDirty() } for (const [sessionId, buffer] of [...this.pendingBuffers]) { const kept = buffer.filter(item => - item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved' - && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved') + item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested') if (kept.length === buffer.length) continue if (kept.length === 0) this.pendingBuffers.delete(sessionId) else this.pendingBuffers.set(sessionId, kept) @@ -855,7 +906,15 @@ export class SessionManager { ...(projectionValues === undefined ? {} : { projectionValues }), } }) - const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) + const pendingInteractions = new Map<SessionId, PendingInteractionStatus>() + for (const [sessionId, interactions] of this.pendingInteractions) { + const statuses = [...interactions.values()] + // The composer selects the first question ahead of approval. Mirror that + // answer order so the sidebar names the interaction the user can act on. + const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] + if (status !== undefined) pendingInteractions.set(sessionId, status) + } + const fresh = flattenLineage(merged, pendingInteractions) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -863,7 +922,7 @@ export class SessionManager { && 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.waitingApproval === entry.waitingApproval + && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index ba69a6951e..1383faea35 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -15,6 +15,9 @@ export interface PendingPayloads { /** Pending-interaction discriminant (the keys of PendingPayloads). */ export type PendingKind = keyof PendingPayloads +/** Session-list summary of the user action currently blocking progress. */ +export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question' + /** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind] diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 49ff8d974f..36ae79eebe 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot { /** * Derive the request-centric read model from one immutable history window. * Compaction participates as a request purpose rather than a parallel - * top-level collection. + * top-level collection. A leading resume/change header exposes its prompt but + * cannot project a change until the preceding header enters the window. * @param entries - Contiguous raw session history. * @returns Requests and call-time schemas derived from that history. */ @@ -218,6 +219,7 @@ function promptChange( prompt: ConversationPromptSnapshot, event: SessionEvent<'request/header'>, ): RequestPromptChange | undefined { + if (previous === undefined && event.data.reason !== 'initial') return const systemChanged = previous !== undefined && previous.system !== prompt.system const toolsChanged = previous !== undefined && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) @@ -240,6 +242,7 @@ function promptChange( function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] { const requests: RequestView[] = [] const ordinaryByStep = new Map<string, number>() + const lastStepByTurn = new Map<number, string>() let activeStep: string | undefined let activePrompt: ConversationPromptSnapshot | undefined let activeCompaction: number | undefined @@ -266,6 +269,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] const { turn, step } = sourceEvent.data const key = requestKey(turn, step) ordinaryByStep.set(key, requests.length) + lastStepByTurn.set(turn, key) requests.push({ purpose: 'assistant', startSeq: sourceEvent.seq, @@ -358,12 +362,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] }) continue } - if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') { - const reason = sourceEvent.data.reason - updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), { - status: 'error', - error: displayFailureMessage('failure' in reason ? reason.failure : reason), - }) + if (sourceEvent.type === 'turn/end') { + const lastStep = lastStepByTurn.get(sourceEvent.data.turn) + if (sourceEvent.data.reason.kind === 'error') { + updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), { + status: 'error', + error: displayFailureMessage(sourceEvent.data.reason.error), + }) + } + lastStepByTurn.delete(sourceEvent.data.turn) continue } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 47ee641053..9399f594d3 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -33,6 +33,7 @@ import type { ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' +import type { PendingInteractionStatus } from './pending.ts' import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' @@ -48,8 +49,8 @@ export interface SessionSummary { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' running: boolean - /** An approval question is pending on this session (sidebar amber-dot state). */ - waitingApproval: boolean + /** User interaction currently blocking this session (sidebar amber-dot state). */ + pendingInteraction?: PendingInteractionStatus /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -613,9 +614,11 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, - waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.pendingInteraction === undefined + ? {} + : { pendingInteraction: entry.pendingInteraction }), ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), @@ -643,7 +646,6 @@ export class SessionsService implements ISessions { parentId: address.parentSessionId, origin: 'subagent', running: child.activity === 'running', - waitingApproval: false, blank: false, updatedAt: 0, } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 65e10a9026..776f4494fd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError, + HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -98,6 +98,8 @@ export class Session implements SessionFace { private readonly transcript = new TranscriptAdapter() private partial: PartialAccumulator | null = null private openCalls = new Map<string, RunningToolCall>() + /** Last entered step per turn, folded from step/start for terminal error placement. */ + private lastStepByTurn = new Map<number, number>() /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. * Derived from window events and rebuilt with partial/openCalls; the transcript is * seq-monotonic, so a plain seq merge preserves event order. */ @@ -271,7 +273,7 @@ export class Session implements SessionFace { } /** Apply one operation to a still-pending queue occurrence. */ - async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> { + async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> { try { return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result } catch (error) { @@ -664,11 +666,12 @@ export class Session implements SessionFace { this.applyEventSideEffects(event, view) } - /** Retire the first matching live steering occurrence when its durable event takes over. */ + /** Retire the first matching live steering occurrence when its durable message takes over. */ private handoffPendingSteering(event: SessionEvent): void { - if (event.type !== 'steering/message') return + if (event.type !== 'user/message') return + const message = event.data const index = this.queued.findIndex(item => - item.placement === 'steering' && item.messageId === event.data.message.id) + item.placement === 'steering' && item.messageId === message.id) if (index === -1) return this.queued = this.queued.filter((_item, candidate) => candidate !== index) this.queueRev++ @@ -803,14 +806,17 @@ export class Session implements SessionFace { return } switch (event.type) { - case 'turn/start': { + case 'turn/start': + this.lastStepByTurn.set(event.data.turn, 0) this.turnTimings.set(event.data.turn, { startTime: event.time }) this.turnTimingsRev++ - if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started') return - } + case 'step/start': + this.lastStepByTurn.set(event.data.turn, event.data.step) + return case 'assistant/chunk': { const { turn, step, chunk } = event.data + this.settleScheduledRetry('started', turn) if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { this.partial = new PartialAccumulator(turn, step) } @@ -837,6 +843,7 @@ export class Session implements SessionFace { return } case 'turn/end': { + const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0 const timing = this.turnTimings.get(event.data.turn) if (timing !== undefined) { this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time }) @@ -844,25 +851,26 @@ export class Session implements SessionFace { } this.turnEnds.set(event.data.turn, event.seq) this.turnEndsRev++ - if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { + if (event.data.reason.kind === 'aborted') { this.settleScheduledRetry('cancelled', event.data.turn) } if ( event.data.reason.kind === 'error' && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn) ) { - const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason + const failure = event.data.reason.error this.derivedNodes.push({ kind: 'turn-error', seq: event.seq, time: event.time, turn: event.data.turn, - step: event.data.reason.step, + step: lastStep, message: displayFailureMessage(failure), - ...(failure.code === undefined ? {} : { code: failure.code }), + code: failure.code, }) this.derivedRev++ } + if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn) // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node @@ -897,6 +905,7 @@ export class Session implements SessionFace { }) this.derivedRev++ } + this.lastStepByTurn.delete(event.data.turn) return } default: @@ -931,6 +940,7 @@ export class Session implements SessionFace { private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() + this.lastStepByTurn.clear() this.callsRev++ this.derivedNodes = [] this.derivedRev++ diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/runtime/src/client/sessions/steering-history.ts new file mode 100644 index 0000000000..0f66025e16 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/steering-history.ts @@ -0,0 +1,65 @@ +/** Reconstruct durable steering identity from the event-sourced agent inbox. */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' + +type InboxTarget = 'next-turn' | 'next-step' + +/** Minimal pending identity retained while replaying durable inbox splices. */ +interface PendingIdentity { + readonly id: string +} + +/** Client-side structural view of the host-owned inbox event. */ +interface InboxSplice { + readonly target: InboxTarget + readonly start: number + readonly removedCount?: number + readonly inserted: readonly PendingIdentity[] + readonly outcome?: 'canceled' +} + +/** + * Incrementally identifies `user/message` events claimed from the next-step + * inbox. The agent loop records all admitted input as `user/message`; the + * preceding `agent/inbox/spliced` events preserve whether it came from the + * queued-turn list or the next-step list. + */ +export class SteeringHistory { + private readonly inbox: Record<InboxTarget, PendingIdentity[]> = { + 'next-turn': [], + 'next-step': [], + } + + private readonly claimedNextStep = new Set<string>() + + /** Clear all replay state before rebuilding a history window. */ + reset(): void { + this.inbox['next-turn'] = [] + this.inbox['next-step'] = [] + this.claimedNextStep.clear() + } + + /** + * Apply one event and report whether it is a durable human steering message. + * @param event - next raw session event in sequence order. + * @returns true only for a user-origin message previously claimed from `next-step`. + */ + apply(event: SessionEvent): boolean { + if ((event.type as string) === 'agent/inbox/spliced') { + this.applySplice(event.data as unknown as InboxSplice) + return false + } + if (event.type !== 'user/message') return false + const id = event.data.id + if (!this.claimedNextStep.delete(id)) return false + return event.data.source.kind === 'user' + } + + /** Replay one host-validated inbox splice. */ + private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void { + const removed = this.inbox[target].splice(start, removedCount, ...inserted) + for (const identity of inserted) this.claimedNextStep.delete(identity.id) + if (target !== 'next-step' || outcome === 'canceled') return + for (const identity of removed) this.claimedNextStep.add(identity.id) + } +} diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 05ac3cb067..306571b2bf 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,6 +22,10 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' +import { contextForm, contextProvenance } from './context-provenance.ts' +import { SteeringHistory } from './steering-history.ts' +import type { AssistantStepMetadata } from './assistant-timing.ts' +import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -29,7 +33,6 @@ import { toAssistantBlocks } from './conversation.ts' * import stays type-only because a value import would fail the client purity * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are * forbidden in a browser bundle — while an erased type never reaches it. - * `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. */ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' @@ -45,11 +48,13 @@ interface CallIndexEntry { callView: ToolCallView | null } -/** One event -> UI node (pure function; the eight-variant ConversationNode union). */ +/** One event -> UI node (pure function; the ten-variant ConversationNode union). */ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap<string, CallIndexEntry>, resultView: ToolResultView | null, + steering: boolean, + stepTimings: ReadonlyMap<string, AssistantStepMetadata>, ): ConversationNode { switch (event.type) { case 'user/message': @@ -60,6 +65,15 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + if (steering) { + return { + kind: 'steering', messageId: event.data.id, + seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, } } return { @@ -71,12 +85,7 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - } - case 'steering/message': - return { - kind: 'steering', messageId: event.data.message.id, - seq: event.seq, time: event.time, turn: event.data.turn, - content: event.data.message.content, source: event.data.message.source, + timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), } case 'tool/result': { const result = event.data.message.content[0] @@ -177,8 +186,12 @@ export class TranscriptAdapter { /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ private projected: ConversationNode[] = [] private callIdx = new Map<string, CallIndexEntry>() + /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ + private stepTimings = new Map<string, AssistantStepMetadata>() /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ private resultViews = new Map<number, ToolResultView>() + /** Durable inbox replay used to distinguish next-step human input from queued prompts. */ + private readonly steeringHistory = new SteeringHistory() /** * Command lifecycle nodes by commandId (insertion = run order). The * `command/run`/`command/done` pair is log-only, so it is not a surface @@ -207,6 +220,9 @@ export class TranscriptAdapter { this.callIdx = new Map() this.resultViews.clear() this.commandIdx = new Map() + this.steeringHistory.reset() + const steeringSeqs = new Set<number>() + this.stepTimings = new Map() for (let i = 0; i < events.length; i++) { const event = events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -214,12 +230,14 @@ export class TranscriptAdapter { this.eventIndex.set(event.seq, event) this.indexCall(event, views?.[i]) this.indexCommand(event) + if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) + indexAssistantStepTiming(this.stepTimings, event) } // Indexes first, then project: a tool/result materializes against the // complete call index, and a checkpoint against the complete event index. const projected: ConversationNode[] = [] for (const event of events) { - if (isTranscriptEvent(event)) projected.push(this.materialize(event)) + if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq))) } this.projected = projected } @@ -236,9 +254,11 @@ export class TranscriptAdapter { append(event: SessionEvent, view?: ToolEventView): void { this.eventIndex.set(event.seq, event) this.indexCall(event, view) + const steering = this.steeringHistory.apply(event) + indexAssistantStepTiming(this.stepTimings, event) if (this.indexCommand(event)) this.rev++ if (!isTranscriptEvent(event)) return - this.projected = [...this.projected, this.materialize(event)] + this.projected = [...this.projected, this.materialize(event, steering)] this.rev++ } @@ -271,10 +291,16 @@ export class TranscriptAdapter { } /** Materialize one transcript event against the complete current indexes. */ - private materialize(event: SessionEvent): ConversationNode { + private materialize(event: SessionEvent, steering: boolean): ConversationNode { return isCompactCheckpoint(event) ? materializeCompaction(event, this.eventIndex) - : materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null) + : materializeNode( + event, + this.callIdx, + this.resultViews.get(event.seq) ?? null, + steering, + this.stepTimings, + ) } /** diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index aeb7100a03..3698f62aab 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -2,12 +2,12 @@ * SlotsService: the cordis Service layer of the slot system over the pure * SlotCore (ui-slots owns registration semantics, the declaration ledger, * the load-time validations, and the unload cascade). This layer owns what - * needs the runtime: the 'slots/changed' event bridge, register through the - * caller's ctx.effect (fiber unload collects registrations), the renderer - * install seam (install()/renderSlot('root') + the SlotRendererHost face), - * and the store INSTANCE axis — handle x scope key -> create/cache, dropped - * with the last holding entry, session instances cleared (with persisted - * state) on scope death. + * needs the runtime: the 'slots/changed' event bridge, register and + * declaration injection through the caller's ctx.effect (fiber unload + * collects both), the renderer install seam (install()/renderSlot('root') + + * the SlotRendererHost face), and the store INSTANCE axis — handle x scope + * key -> create/cache, dropped with the last holding entry, session instances + * cleared (with persisted state) on scope death. */ /* oxlint-disable typescript/no-redundant-type-constituents -- * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only @@ -78,6 +78,9 @@ interface ErasedRegisterOptions { /** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */ interface ErasedCore { register(options: object, component: unknown): () => void } +/** One synchronous effect installed while an injected slot declaration is live. */ +type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void> + /** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */ export class SlotsService extends Service { private readonly _core = new SlotCore() @@ -114,6 +117,85 @@ export class SlotsService extends Service { */ declare readonly register: SlotCore['register'] + /** + * Install an effect for each declaration lifetime of a slot. The callback + * runs synchronously when the declaration already exists; otherwise it runs + * inside the declaring `register()` call after the declaration is committed. + * Collapse disposes the effect and a later declaration runs it again. + * Callback effects are synchronous disposers; iterable effects install + * transactionally and dispose in reverse order. The controller belongs to + * the caller's fiber, so plugin unload cancels a pending wait and removes any + * active contribution. + * + * @param key - declared SlotMap key to depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the wait and active effect. + * @throws callback setup failures synchronously when the slot is already declared. + */ + inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void { + const ctx = this.ctx + const disposeController = ctx.effect(() => { + let active: (() => void) | undefined + let activeEpoch: number | undefined + let stopped = false + let unsubscribe = (): void => {} + + const stop = (): void => { + if (stopped) return + // Failure callers retire the injection permanently: a delayed setup + // failure never retries on a later declaration. + stopped = true + unsubscribe() + const dispose = active + active = undefined + activeEpoch = undefined + dispose?.() + } + + const reconcile = (): void => { + if (stopped) return + const spec = this._core.specDynamic(key) + const epoch = this._core.declarationEpoch(key) + if (active !== undefined && activeEpoch === epoch) return + const dispose = active + active = undefined + activeEpoch = undefined + dispose?.() + if (spec === undefined) return + // A declaration lifetime is a nested Cordis effect. This gives + // generator callbacks the same transactional setup, reverse teardown, + // diagnostics tree, and idempotence as every other plugin effect. + const disposeEffect = ctx.effect(callback, `slots.inject(${JSON.stringify(key)}): declaration`) + active = () => { void disposeEffect() } + activeEpoch = epoch + } + + const changed = (): void => { + try { + reconcile() + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') { + stop() + return + } + stop() + const failure = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => { throw failure }) + } + } + + unsubscribe = this._core.subscribeDeclaration(key, changed) + try { + reconcile() + } catch (error) { + stop() + throw error + } + return stop + }, `slots.inject(${JSON.stringify(key)})`) + return () => { void disposeController() } + } + /** * Install the shell's renderer (web-react's createSlotRenderer product). * Boot-once: a second install throws. Runs through the caller's ctx.effect, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index a0a76670f2..69910fd0c4 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces { // would miss the reuse scan and mint another hidden blank session. const inflight = this.connecting.get(workspaceId) if (inflight !== undefined) return inflight - // Reuse: blank && same canonical cwd (workspace.path is the host realpath - // canon; summary cwd is the session header passthrough of the same canon). - // An archived blank is never reused: reuse would open a session no - // grouping surface can show, so New Session mints a fresh one instead. + // Reuse requires workspace membership (id in sessionIds AND same + // canonical cwd — the host's own membership rule), never cwd alone: + // a cwd match can belong to no account (sessions the CLI/TUI birthed at + // the host cwd, or a deleted/recreated registration) and reusing it + // would open a session no grouping surface shows under this workspace. + // An archived blank is never reused either: reuse would open a session + // no grouping surface can show, so New Session mints a fresh one instead. const archived = this.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() for (const id of sessions.ids) { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path + && workspace.sessionIds.includes(summary.id) && !archived.includes(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.spec.ts new file mode 100644 index 0000000000..11d8931439 Binary files /dev/null and b/packages/client/runtime/tests/context-provenance.spec.ts differ diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 3e9f82b883..ebd64dc60f 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent => export const ev = { turnStart: (seq: number, turn: number): SessionEvent => - at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }), + at(seq, { type: 'turn/start', data: { turn } }), user: (seq: number, body: string): SessionEvent => at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: text(body), source: { kind: 'user' }, @@ -82,7 +82,12 @@ export const ev = { }, }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => - at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + at(seq, { type: 'turn/end', data: { + turn, + reason: reason === 'completed' + ? { kind: 'completed' } + : { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } }, + } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 3c0b86fec8..888a630de1 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -216,7 +216,8 @@ export class FakeApiClient implements IApiClient { } readonly settings: IApiClient['settings'] = { - describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + 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 }))), diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 0c5421e491..083bdc3566 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -1,13 +1,89 @@ -import { createMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' +import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts' +import { inspectRequests } from '../src/client/sessions/request-inspection.ts' import { ev } from './event-script.ts' const at = (seq: number, event: Record<string, unknown>): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent describe('projectConversationHistory', () => { + it('names an injected context node from its durable source, like the live adapter', () => { + // The fold declares its own node mapping (jscpd:ignore in the source), so + // the provenance projection is pinned on both sides independently. + const injected = at(0, { + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [{ type: 'text', text: '<available_skills>…</available_skills>' }], + // A plugin source, because the client program does not see the host + // packages that merge richer source kinds; those arms are pinned in + // context-provenance.spec.ts. + source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' }, + }), + }) + const { contexts } = projectConversationHistory([{ event: injected }]) + expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ + kind: 'context', + seq: 0, + provenance: { role: 'inject', label: 'dsh-tool-skill' }, + form: 'catalog', + }]) + }) + + it('projects next-step human input as durable steering', () => { + const steering = createUserMessage({ + content: [{ type: 'text', text: 'change course' }], + source: { kind: 'user' }, + }) + const events = [ + at(0, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, inserted: [steering], + } }), + at(1, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + } }), + at(2, { type: 'user/message', surfaceOp: 'append', data: steering }), + ] + const projection = projectConversationHistory(events.map(event => ({ event }))) + expect(projection.eventNodes).toMatchObject([{ + kind: 'steering', messageId: steering.id, seq: 2, + }]) + }) + + it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { + const baseSeq = 400_000 + const events = [ + ev.user(baseSeq, 'loaded tail'), + at(baseSeq + 1, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + data: { + turn: 80, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'tail summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + ] + + const projection = projectConversationHistory(events.map(event => ({ event }))) + expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1]) + expect(projection.contexts.map(context => ({ + originSeq: context.originSeq, + nodes: context.nodes.map(node => node.seq), + }))).toEqual([ + { originSeq: undefined, nodes: [baseSeq] }, + { originSeq: baseSeq + 1, nodes: [baseSeq + 1] }, + ]) + }) + it('projects frozen surface generations without widening the core live surface', () => { const events = [ ev.user(0, 'a'), @@ -91,4 +167,35 @@ describe('projectConversationHistory', () => { requestConfig: { provider: 'fake', model: 'first' }, }) }) + + it('drops completed token payloads without changing inspection projections', () => { + const events = [ + ev.user(0, 'before'), + ev.stepStart(1, 1, 0), + ev.chunkStart(2, 1), + ev.chunkText(3, 1, ''), + ev.chunkText(4, 1, 'first'), + ev.chunkText(5, 1, ' discarded'), + at(6, { type: 'assistant/chunk', data: { + turn: 1, + step: 0, + chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }, + } }), + ev.assistant(7, 1, 'first discarded'), + ev.compactSummary(8, 'summary', 0, 7), + ev.compactCheckpoint(9, 8, 0, 7), + ev.stepStart(10, 2, 0), + ev.chunkStart(11, 2), + ev.chunkText(12, 2, 'interrupted'), + ev.turnEnd(13, 2, 'aborted'), + ] + const raw = events.map(event => ({ event })) + const compacted = compactHistoryInspectionEntries(raw) + + expect(compacted.map(entry => entry.event.seq)).toEqual([ + 0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13, + ]) + expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw)) + expect(inspectRequests(compacted)).toEqual(inspectRequests(raw)) + }) }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index f6d7baf318..909a293b3e 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -40,6 +40,7 @@ describe('instances', () => { const manager = new SessionManager(api) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) const session = manager.get(S1) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) @@ -47,16 +48,26 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) - it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { + it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) - // 40 distinct question frames for an uninstantiated session: only the newest 32 survive. + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) } - const pending = manager.get(S1).getSnapshot().pending - expect(pending).toHaveLength(32) - expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + for (let i = 0; i < 40; i++) { + manager.handleMuxEnvelope({ + rpcId: `r${i}` as never, + payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, + }) + } + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + expect(manager.get(S1).getSnapshot().pending).toEqual([]) + }) + + it('drops buffered answerable requests on session removal', () => { + const manager = new SessionManager(new FakeApiClient()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) @@ -862,48 +873,102 @@ describe('connected generation', () => { }) }) -describe('waiting-approval list bit', () => { - it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { +describe('pending-interaction list status', () => { + it('tracks approval requests through replay and resolution without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Mux-open replay of the same question (same approvalId) is idempotent. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) - it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ + rpcId: 'q2' as never, + payload: { + type: 'question/requested', + sessionId: S1, + questions: [{ + id: 'plan', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Refuse' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + }) + + it.each([ + ['missing detail', {}], + ['multi-select', { detail: '# Plan', multiSelect: true }], + ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], + ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], + ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q-plan' as never, + payload: { + type: 'question/requested', sessionId: S1, + questions: [{ + id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + ...over, + }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + }) + + it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) - manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) - // Removed sessions drop their bit outright. - manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => { + it('drops stale status at generation death before replay re-adds live interactions', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Generation death clears (resolved-while-disconnected questions send no frame)… manager.handleDisconnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() // …and a replayed frame arriving before onConnected (stream open precedes // the readiness handshake) survives the later handleConnected untouched. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleConnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 82d422aadc..55e0929c30 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -7,9 +7,7 @@ import { describe, expect, it } 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 { - InboxItemId, MuxFrame, RpcId, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient } from './fake-api.ts' @@ -17,7 +15,7 @@ import { FakeApiClient } from './fake-api.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): InboxItemId => id as InboxItemId +const iid = (id: string): MessageId => id as MessageId interface QueueFixture { id: string @@ -151,16 +149,16 @@ describe('queue snapshot intake', () => { const durable = { seq: 0, time: 1_700_000_000_000, - type: 'steering/message', + type: 'user/message', surfaceOp: 'append', - data: { turn: 1, message }, + data: message, } as SessionEvent session.handleMuxEnvelope(rid('env-durable'), { type: 'session/event', sessionId: SID, event: durable, }) expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1) session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, @@ -170,6 +168,32 @@ describe('queue snapshot intake', () => { }) 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 session = makeSession() + await session.open() + const message = createUserMessage({ + content: text('claimed steering'), + source: { kind: 'user' }, + }) + session.handleMuxEnvelope(rid('env-claimed'), queueFrame([ + { id: 's-claimed', body: '', placement: 'steering', message }, + ])) + + session.handleMuxEnvelope(rid('env-user-message'), { + type: 'session/event', + sessionId: SID, + event: { + seq: 0, + time: 1_700_000_000_000, + type: 'user/message', + surfaceOp: 'append', + data: message, + }, + }) + + expect(session.getSnapshot().queue).toEqual([]) + }) }) describe('queue operation transport', () => { diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index f354d14654..031c2f4b45 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -85,6 +85,56 @@ describe('inspectRequests', () => { expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') }) + it('does not promote a truncated resume or change header to the initial prompt', () => { + for (const reason of ['resume', 'change'] as const) { + const snapshot = inspectRequests(entriesOf([ + at(10, 'step/start', { turn: 3, step: 1 }), + at(11, 'request/header', { + reason, + header: { + config: { provider: 'fake', model: 'model' }, + system: 'tail-window prompt', + }, + }), + ])) + + expect(snapshot.requests[0]).toMatchObject({ + purpose: 'assistant', + prompt: { system: 'tail-window prompt' }, + }) + expect(snapshot.requests[0]).not.toHaveProperty('promptChange') + } + }) + + it('classifies a prompt change once the preceding header is loaded', () => { + const snapshot = inspectRequests(entriesOf([ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'model' }, + system: 'before', + }, + }), + at(2, 'step/start', { turn: 1, step: 2 }), + at(3, 'request/header', { + reason: 'change', + header: { + config: { provider: 'fake', model: 'model' }, + system: 'after', + }, + }), + ])) + + expect(snapshot.requests[1]).toMatchObject({ + promptChange: { + seq: 3, + kind: 'system', + previous: { system: 'before' }, + }, + }) + }) + it('preserves a standalone compaction owner without widening assistant turns', () => { const snapshot = inspectRequests(entriesOf([ at(0, 'compact/start', { turn: null }), @@ -225,20 +275,15 @@ describe('inspectRequests', () => { const snapshot = inspectRequests(entriesOf([ at(0, 'step/start', { turn: 1, step: 1 }), at(1, 'turn/end', { - turn: 1, - reason: { - kind: 'error', - step: 1, - failure: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, + turn: 1, reason: { kind: 'error', error: { + code: 'AUTH', + message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', + }, }, }), at(2, 'step/start', { turn: 2, step: 1 }), at(3, 'turn/end', { - turn: 2, - reason: { kind: 'error', step: 1, message: 'plugin exploded' }, + turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } }, }), ])) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index bcf25cd933..2bc0aa87af 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) { } describe('SessionHistorySource', () => { - it('loads every older page without changing a Chat session', async () => { + it('loads the tail first and prepends older pages on demand', async () => { const pages = [ plainTurn(0, 0, '最早问', '最早答'), plainTurn(6, 1, '中间问', '中间答'), @@ -30,10 +30,21 @@ describe('SessionHistorySource', () => { } const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() + + expect(api.callsOf('session.history')).toHaveLength(1) + expect(source.getSnapshot().hasMore).toBe(true) + expect(source.getSnapshot().baseSeq).toBe(12) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([13, 15]) + + expect(await source.loadOlder()).toBe(true) + expect(await source.loadOlder()).toBe(true) + expect(await source.loadOlder()).toBe(false) expect(api.callsOf('session.history')).toHaveLength(3) expect(source.getSnapshot().hasMore).toBe(false) + expect(source.getSnapshot().baseSeq).toBe(0) expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) .toEqual([1, 3, 7, 9, 13, 15]) }) @@ -42,7 +53,7 @@ describe('SessionHistorySource', () => { const api = new FakeApiClient() api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() const before = source.getSnapshot() source.handleMuxFrame({ @@ -60,7 +71,7 @@ describe('SessionHistorySource', () => { const api = new FakeApiClient() api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback) @@ -132,13 +143,14 @@ describe('SessionHistorySource', () => { })) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() + expect(await source.loadOlder()).toBe(false) expect(api.callsOf('session.history')).toHaveLength(2) expect(source.getSnapshot().hasMore).toBe(true) }) - it('observes consumer cancellation between older pages', async () => { + it('finishes an already started older page after consumer cancellation', async () => { const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>() const olderStarted = deferred<undefined>() const api = new FakeApiClient() @@ -151,7 +163,8 @@ describe('SessionHistorySource', () => { } const source = new SessionHistorySource(SID, api) const controller = new AbortController() - const complete = source.loadAll(controller.signal) + await source.loadTail(controller.signal) + const complete = source.loadOlder(controller.signal) await olderStarted.promise controller.abort() middle.resolve(ok({ @@ -159,7 +172,7 @@ describe('SessionHistorySource', () => { hasMore: true, })) - await complete + expect(await complete).toBe(true) expect(api.callsOf('session.history')).toHaveLength(2) expect(source.getSnapshot().hasMore).toBe(true) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index e1c9bbeae7..c288c044ee 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -206,7 +206,7 @@ describe('live event path', () => { expect(published).toEqual(['累计', null]) }) - it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => { + it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } const retryTurn = [ @@ -215,25 +215,13 @@ describe('live event path', () => { ev.stepStart(8, 1), ev.chunkStart(9, 1), ev.chunkText(10, 1, '不完整回复'), - ev.stepEnd(11, 1), - ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'), - at(13, { - type: 'turn/end', - data: { - turn: 1, - reason: { - kind: 'error', step: 0, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - }, - }, - }), - at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }), - ev.stepStart(15, 2), - ev.assistant(16, 2, '完整回复'), - ev.stepEnd(17, 2), - ev.turnEnd(18, 2), + ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'), + ev.chunkStart(12, 1), + ev.assistant(13, 1, '完整回复'), + ev.stepEnd(14, 1), + ev.turnEnd(15, 1), ] - for (const event of retryTurn.slice(0, 7)) feed(event) + for (const event of retryTurn.slice(0, 6)) feed(event) let snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() @@ -252,15 +240,14 @@ describe('live event path', () => { }) expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') - for (const event of retryTurn.slice(7)) feed(event) + for (const event of retryTurn.slice(6)) feed(event) snapshot = session.getSnapshot() expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false) expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) - const retryStart = retryTurn.find(event => - event.type === 'turn/start' && event.data.trigger.kind === 'retry') - if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start') + const retryStart = retryTurn.find(event => event.type === 'turn/start') + if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start') const retryEnd = retryTurn.find(event => event.type === 'turn/end' && event.data.turn === retryStart.data.turn) if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn') @@ -285,35 +272,33 @@ describe('live event path', () => { const failedTurns = [ ev.turnStart(6, 1), ev.user(7, '鉴权失败'), - at(8, { + ev.stepStart(8, 1), + at(9, { type: 'turn/end', - data: { - turn: 1, - reason: { - kind: 'error', - step: 0, - failure: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, + data: { turn: 1, reason: { kind: 'error', error: { + code: 'AUTH', + message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', + }, + }, }, }), - ev.turnStart(9, 2), - ev.user(10, '内部失败'), - at(11, { + ev.turnStart(10, 2), + ev.user(11, '内部失败'), + ev.stepStart(12, 2, 1), + at(13, { type: 'turn/end', - data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } }, + data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } }, }), ] for (const event of failedTurns) feed(event) const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error') expect(errors).toMatchObject([ - { seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, - { seq: 11, turn: 2, step: 1, message: 'plugin exploded' }, + { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, + // Every failed turn carries a structured failure; unstructured errors + // flatten to the UNKNOWN code. + { seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' }, ]) - expect('code' in errors[1]!).toBe(false) const replay = makeSession() replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns]) @@ -450,7 +435,7 @@ describe('live event path', () => { }) it.each(['aborted', 'disposed'] as const)( - 'marks a scheduled retry as cancelled when its failed turn ends %s', + 'marks a scheduled retry as cancelled when its failed turn receives the %s cause', async (reason) => { const { session } = await opened() const feed = (event: SessionEvent) => { @@ -470,6 +455,24 @@ describe('live event path', () => { }, ) + it('marks a scheduled retry as started when its failed turn ends with an error', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + feed(ev.turnStart(6, 1)) + feed(ev.retry(7, 1)) + feed(at(8, { + type: 'turn/end', + data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } }, + })) + + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'started', + }) + }) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index b2e510a26f..f8968926c1 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -29,6 +29,7 @@ const C: FC<object> = () => null */ interface ErasedService { register(options: object, component: unknown): () => void + inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void install(renderer: object): void renderSlot(key: string, owner: object): unknown } @@ -166,6 +167,266 @@ describe('load-time validation', () => { }) }) +describe('declaration injection', () => { + it('activates immediately and ignores ordinary entry mutations', async () => { + const bench = await boot() + bench.erased.register({ + name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } }, + }, C) + const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C)) + const dispose = bench.erased.inject('t.rows', setup) + expect(setup).toHaveBeenCalledOnce() + bench.erased.register({ name: 't.rows', id: 'ordinary' }, C) + await Promise.resolve() + expect(setup).toHaveBeenCalledOnce() + dispose() + expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary']) + }) + + it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => { + const bench = await boot() + const cleanup = vi.fn() + const setup = vi.fn(() => { + const unregister = bench.erased.register({ name: 't.host' }, C) + return () => { unregister(); cleanup() } + }) + bench.erased.inject('t.host', setup) + expect(setup).not.toHaveBeenCalled() + const disposeFrame = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + await Promise.resolve() + expect(setup).toHaveBeenCalledOnce() + expect(bench.svc.entries('t.host')).toHaveLength(1) + disposeFrame() + await Promise.resolve() + expect(cleanup).toHaveBeenCalledOnce() + expect(bench.svc.entries('t.host')).toHaveLength(0) + bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + await Promise.resolve() + expect(setup).toHaveBeenCalledTimes(2) + expect(bench.svc.entries('t.host')).toHaveLength(1) + }) + + it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => { + const bench = await boot() + const firstFrame = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + const cleanup = vi.fn() + const setup = vi.fn(() => { + const unregister = bench.erased.register({ name: 't.host' }, C) + return () => { unregister(); cleanup() } + }) + bench.erased.inject('t.host', setup) + firstFrame() + bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + await Promise.resolve() + expect(cleanup).toHaveBeenCalledOnce() + expect(setup).toHaveBeenCalledTimes(2) + expect(bench.svc.entries('t.host')).toHaveLength(1) + }) + + it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => { + const active = await boot() + active.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + const activeFiber = active.ctx.plugin({ + name: 'active-injection', + inject: ['slots'], + apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) }, + }) + await activeFiber.await() + expect(active.svc.entries('t.host')).toHaveLength(1) + await activeFiber.dispose() + expect(active.svc.entries('t.host')).toHaveLength(0) + + const waiting = await boot() + const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C)) + const waitingFiber = waiting.ctx.plugin({ + name: 'waiting-injection', + inject: ['slots'], + apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) }, + }) + await waitingFiber.await() + await waitingFiber.dispose() + waiting.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + await Promise.resolve() + expect(setup).not.toHaveBeenCalled() + }) + + it('rolls back earlier yielded registrations when generator setup fails', async () => { + const bench = await boot() + bench.erased.register({ + name: 'root', + children: { + 't.host': { kind: 'single', scope: 'root' }, + 't.rows': { kind: 'list', scope: 'root' }, + }, + }, C) + bench.erased.register({ name: 't.host' }, C) + expect(() => bench.erased.inject('t.rows', function* () { + yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C) + yield bench.erased.register({ name: 't.host' }, C) + })).toThrow(/already has a registration/) + expect(bench.svc.entries('t.rows')).toHaveLength(0) + }) + + it('contains and wraps a delayed setup failure so later slot listeners still run', async () => { + const bench = await boot() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const setup = vi.fn(function* () { + yield bench.erased.register({ name: 't.host' }, C) + throw null + }) + bench.erased.inject('t.host', setup) + const later = vi.fn(() => () => undefined) + bench.erased.inject('t.host', later) + const disposeFrame = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(failures).toHaveLength(1) + expect(failures[0]).toBeInstanceOf(Error) + expect(String(failures[0])).toContain('null') + expect(later).toHaveBeenCalledOnce() + disposeFrame() + bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + expect(setup).toHaveBeenCalledOnce() + } finally { + process.off('uncaughtException', onLoud) + } + }) + + it('skips a stopped controller retained by the current declaration snapshot', async () => { + const bench = await boot() + let stopLater = (): void => {} + const first = vi.fn(() => { + stopLater() + return () => undefined + }) + const later = vi.fn(() => () => undefined) + bench.erased.inject('t.host', first) + stopLater = bench.erased.inject('t.host', later) + + bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + expect(first).toHaveBeenCalledOnce() + expect(later).not.toHaveBeenCalled() + }) + + it('keeps a nested redeclaration activation when the outer collapse resumes', async () => { + const bench = await boot() + const disposeFrame = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + let disposeReplacement = (): void => {} + let replaced = false + const first = vi.fn(() => () => { + if (replaced) return + replaced = true + disposeReplacement = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + }) + const later = vi.fn(() => () => undefined) + bench.erased.inject('t.host', first) + bench.erased.inject('t.host', later) + + disposeFrame() + expect(first).toHaveBeenCalledTimes(2) + expect(later).toHaveBeenCalledTimes(2) + expect(bench.svc.spec('t.host')).toBeDefined() + disposeReplacement() + }) + + it('cancels a waiting injection when its contributor is already unloading', async () => { + const bench = await boot() + const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C)) + let release = (): void => {} + const blocked = new Promise<void>((resolve) => { release = resolve }) + const pauseUnload = vi.fn(async () => { await blocked }) + const contributor = bench.ctx.plugin({ + name: 'unloading-injection', + inject: ['slots'], + apply: (ctx: Context) => { + ctx.slots.inject('t.host', setup) + ctx.effect(() => pauseUnload, 'pause contributor unload') + }, + }) + await contributor.await() + const disposing = contributor.dispose() + expect(() => bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C)).not.toThrow() + expect(setup).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() }) + release() + await disposing + }) + + it('supports dynamic plugin replacement without retaining the old rendered entry', async () => { + const bench = await boot() + bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + const componentA = (): null => null + const componentB = (): null => null + const mount = (name: string, component: FC<object>) => bench.ctx.plugin({ + name, + inject: ['slots'], + apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) }, + }) + const first = mount('replacement-a', componentA) + await first.await() + expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA) + await first.dispose() + expect(bench.svc.entries('t.host')).toHaveLength(0) + const second = mount('replacement-b', componentB) + await second.await() + expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB) + }) + + it('releases service-layer store state when the declaration collapses', async () => { + const bench = await boot() + let host: SlotRendererHost | undefined + bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } }) + bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) + const disposeFrame = bench.erased.register({ + name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, + }, C) + bench.erased.renderSlot('root', {}) + if (host === undefined) throw new Error('renderer never received the host') + const { handle } = fakeHandle() + bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C)) + const oldEntry = host.entriesOf('t.host')[0] + expect(host.storeOf(oldEntry as never, undefined)).toBeDefined() + disposeFrame() + expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/) + bench.erased.register({ + name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } }, + }, C) + bench.erased.register({ name: 't.panel', store: handle }, C) + const panelEntry = host.entriesOf('t.panel')[0] + expect(host.storeOf(panelEntry as never, 's1')).toBeDefined() + expect(handle.create).toHaveBeenLastCalledWith('s1') + }) +}) + describe('renderer install seam', () => { it('throws on renderSlot before install (boot-order guidance)', async () => { const bench = await boot() diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index cc03d349b8..031acf1780 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -85,29 +85,85 @@ describe('TranscriptAdapter', () => { it('materializes every append-origin variant with field mapping', () => { const adapter = new TranscriptAdapter() + const steering = createUserMessage({ + content: [{ type: 'text', text: '插话' }], + source: { kind: 'user' }, + }) adapter.reset([ ev.user(0, '用户'), ev.assistant(1, 0, '助手'), - at(2, { type: 'steering/message', surfaceOp: 'append', data: { - turn: 0, - message: createUserMessage({ - content: [{ type: 'text', text: '插话' }], - source: { kind: 'user' }, - }), + at(2, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, inserted: [steering], } }), - at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + at(3, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + } }), + at(4, { type: 'user/message', surfaceOp: 'append', data: steering }), + at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' }, }) }), - ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'), - ev.toolResult(5, 0, 'c1', '结果'), + ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'), + ev.toolResult(7, 0, 'c1', '结果'), ]) const nodes = adapter.nodes() expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result']) + expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id }) expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false, }) }) + it('identifies steering on the live append path', () => { + const adapter = new TranscriptAdapter() + const steering = createUserMessage({ + content: [{ type: 'text', text: 'live steer' }], + source: { kind: 'user' }, + }) + adapter.reset([]) + adapter.append(at(0, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, inserted: [steering], + } })) + adapter.append(at(1, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + } })) + adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering })) + expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }]) + }) + + it('does not mark queued, canceled, or non-user next-step messages as steering', () => { + const adapter = new TranscriptAdapter() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } }) + const context = createUserMessage({ + content: [{ type: 'text', text: 'context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + adapter.reset([ + at(0, { type: 'agent/inbox/spliced', data: { + target: 'next-turn', start: 0, inserted: [queued], + } }), + at(1, { type: 'agent/inbox/spliced', data: { + target: 'next-turn', start: 0, removedCount: 1, inserted: [], + } }), + at(2, { type: 'user/message', surfaceOp: 'append', data: queued }), + at(3, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, inserted: [canceled], + } }), + at(4, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', + } }), + at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }), + at(6, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, inserted: [context], + } }), + at(7, { type: 'agent/inbox/spliced', data: { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + } }), + at(8, { type: 'user/message', surfaceOp: 'append', data: context }), + ]) + expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core @@ -205,10 +261,15 @@ describe('TranscriptAdapter', () => { adapter.reset([ at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact' }, + source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, }) }), ]) - expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }]) + expect(adapter.nodes()).toMatchObject([{ + kind: 'context', + seq: 0, + provenance: { role: 'inject', label: 'compact' }, + form: 'instructions', + }]) }) it('ignores a foreign plugin s replacement user/message', () => { @@ -415,4 +476,48 @@ describe('TranscriptAdapter', () => { expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) }) }) + + describe('assistant timing', () => { + const base = 1_700_000_000_000 + + it('derives step timing across a window rebuild (start + first token + completion)', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + ev.turnStart(0, 0), + ev.user(1, '问'), + ev.stepStart(2, 0), + ev.chunkStart(3, 0), + ev.chunkText(4, 0, '答'), + ev.chunkText(5, 0, '案'), + ev.assistant(6, 0, '答案'), + ev.turnEnd(7, 0), + ]) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, + }) + }) + + it('derives the same timing on the live append path, first token winning once', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.user(0, '问')]) + adapter.append(ev.stepStart(1, 0)) + adapter.append(ev.chunkText(2, 0, '首')) + adapter.append(ev.chunkText(3, 0, '次')) + adapter.append(ev.assistant(4, 0, '首次')) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, + }) + }) + + it('soft-falls to null boundaries when the step opening fell outside the window', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, + }) + }) + }) }) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 4323d7ffce..832a1ff71a 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -149,26 +149,37 @@ describe('WorkspacesService', () => { expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) }) - it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => { + it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { const ctx = new Context() const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('alpha'), workspace('beta')] as never[], + items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], })) api.onList = () => Promise.resolve(ok({ items: [ - // Blank session already parked in alpha (cwd == workspace path canon). + // Stray blank at alpha's path but NOT accounted under alpha (a CLI + // session birthed at the host cwd), sorted before the member blank: + // the scan must skip it and keep looking for a member hit. + { sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' }, + // Blank session parked in alpha (cwd == workspace path canon AND + // accounted under alpha): the reuse hit. { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }, // Non-blank sibling in beta must never be reused. { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' }, + // Stray blank at gamma's path but NOT accounted under gamma (a CLI + // session birthed at the host cwd): cwd alone must not hijack it — + // reuse would open a session gamma cannot show, so New Session mints + // a fresh accounted one instead. + { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' }, ] as never[], })) await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() - // Hit: same workspace → the parked blank session comes back, no create RPC. + // Hit: same workspace → the parked member blank comes back (the earlier + // cwd-matching non-member stray is skipped), no create RPC. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') expect(api.callsOf('session.create')).toEqual([]) // Resolution guarantee: the id is binding-resolvable synchronously. @@ -181,6 +192,12 @@ describe('WorkspacesService', () => { // Same guarantee on the create arm (draft hand-off writes the machine pre-open). expect(sessions.binding(sid('s-fresh'))).toBeDefined() + // Miss: the stray blank matches gamma's path but is not a gamma member → + // never reused, a fresh accounted session is created instead. + api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) + await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') + expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) + // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) @@ -196,7 +213,7 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], })) diff --git a/packages/client/schema-form/README.i18n.yaml b/packages/client/schema-form/README.i18n.yaml index f6e939d878..1c3c759ceb 100644 --- a/packages/client/schema-form/README.i18n.yaml +++ b/packages/client/schema-form/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/schema-form/README.md -README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c -README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db +README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac +README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46 diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md index 5dcef89cbf..716cc7ba3c 100644 --- a/packages/client/schema-form/README.md +++ b/packages/client/schema-form/README.md @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work). -- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it. -- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes. +- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation. +- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls. +- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off. diff --git a/packages/client/schema-form/README.zh.md b/packages/client/schema-form/README.zh.md index a82acb7d85..65b8767df8 100644 --- a/packages/client/schema-form/README.zh.md +++ b/packages/client/schema-form/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包(package)不含任何 React,也不做任何渲染。 +面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。 ## 契约 @@ -18,6 +18,6 @@ ## Known Limitations and Deferred Work -- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。 -- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。 +- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示。 +- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。 +- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡。 diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index 175133894a..c1cd5e8018 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -33,8 +33,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index fe40088b11..26845c1d5c 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md -README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031 -README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e +README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e +README.zh.md: a86b9e469a5632886891628267002a14588afeaa diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index dc8ee8cadf..74da8fde7f 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists. +- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry. - **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index a4c889d8a0..a86b9e469a 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -20,5 +20,5 @@ ## Known Limitations and Deferred Work -- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。 +- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。 - **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。 diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index e892d9cd52..c93a302124 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -49,8 +49,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 1d20a512f5..e313b63fd2 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -222,7 +222,6 @@ export class TestSessions implements ISessions { id, displayTitle: fixture.id, running: false, - waitingApproval: false, blank: false, updatedAt: this.records.size + 1, ...fixture.summary, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index cd736883eb..1e45991080 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -69,7 +69,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). * @returns tsdown user configs emitting lib/*.js and lib/client.js. */ -export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] { +export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] { return [{ entry: [...libEntry], outDir: 'lib', diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index fb3743a8a7..e8bce8520f 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-command/README.md -README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4 -README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f +README.md: a19bfe7135acc5408448dc73d04813ed4104dd48 +README.zh.md: 79d903b916728c1200ab1311055f2be190008787 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index c892f2f244..a19bfe7135 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -22,5 +22,4 @@ None directly; this package neither assembles nor sends a provider request. Comm ## Known Limitations and Deferred Work -- **The popupSelect shell has no shipped business consumer** — model selection (host `selectModel`) is the design's reference case and lands with its own feature work; until then the shell is exercised by package tests only. - **Detached-result notices fall back to the console off-session** — the fire-and-forget paths route results to the triggering session's composer via `SessionInput.notify`; after session teardown the console line is the only remaining surface. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index ed607de783..79d903b916 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 `src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 @@ -22,5 +22,4 @@ ## 已知限制与暂缓事项 -- **popupSelect 壳还没有已上架的业务消费者**:模型选择(host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。 - **脱离会话后,detached result 的 notice 回退到 console**:fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器;会话拆除后,console 输出行是仅剩的呈现面。 diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 8144aea6c8..30f23bcbf2 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -69,8 +69,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-command/src/client/PopupSelectView.module.css b/packages/client/ui-command/src/client/PopupSelectView.module.css index 16e1e5c11d..cd457cc376 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.module.css +++ b/packages/client/ui-command/src/client/PopupSelectView.module.css @@ -12,7 +12,10 @@ padding: 4px; display: flex; flex-direction: column; - min-width: 220px; + min-width: min(220px, 100%); + /* Never wider than the composer card (the overlay anchor's width): long + rows truncate instead of pushing the card past the composer's edge. */ + max-width: 100%; /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in PopupSelectView.tsx). */ max-height: 320px; @@ -51,7 +54,8 @@ } .label { - flex: 1; + flex: 1 1 auto; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -61,6 +65,8 @@ font-size: 12px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .check { diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index 76f3d8c551..d65ab0f78e 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -55,14 +55,10 @@ export const inject = ['slash', 'sessions', 'connection', 'locale'] export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries') ctx.plugin(CommandService) - // Conditional mount, same seam as ui-slash's MenuView registration: - // 'conversation.input.overlay' is declared by the conversation composer - // entry, and the conversation service's presence is the registration-safe - // signal that the declaration is on the ledger. - ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => { + ctx.inject(['slots', 'command', 'sessions'], (scope: ClientContext) => { const command = scope.command const sessions = scope.sessions - scope.effect(() => scope.slots.register({ + scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'command-popup', order: 1, @@ -72,6 +68,6 @@ export function apply(ctx: ClientContext): void { if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`) return { popup: command.popupFor(actx) } }, - }, PopupSelectView), 'ui-command: popupSelect overlay registration') + }, PopupSelectView)) }) } diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index bb49cdf4d1..f093548f3e 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -2,13 +2,13 @@ * ui-command browser half on a real cordis Context with fake slash/slots * faces and real session scopes: the plugin body mounts CommandService as * `command`, the popupSelect shell registers into conversation.input.overlay - * once the conversation seam is up with a per-session inject (sessionId → + * through slot declaration injection with a per-session inject (sessionId → * scope → popupFor; unknown id fails loud), both fold up on fiber disposal * (HMR safety), and the service satisfies the frozen CommandServiceContract. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandServiceContract } from '../src/client/contract.ts' @@ -21,7 +21,6 @@ const sid = (k: string): SessionId => k as SessionId async function bench() { const ctx = new Context() const sources = new Map<string, SlashSource>() - const overlays = new Map<string, { inject: unknown }>() ctx.provide('slash', { registerSource(src: SlashSource) { sources.set(`${src.trigger} ${src.name}`, src) @@ -34,14 +33,10 @@ async function bench() { scopeOf: (c: Context) => scopeOf(c), }) ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } }) - ctx.provide('slots', { - register(options: { name: string; id?: string; inject?: unknown }) { - const key = `${options.name}#${options.id ?? ''}` - overlays.set(key, { inject: options.inject }) - return () => { overlays.delete(key) } - }, - }) - ctx.provide('conversation', {}) + await ctx.plugin(SlotsService).await() + ctx.slots.register({ + name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } }, + } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -50,7 +45,7 @@ async function bench() { scopes.set(sid(key), handle.ctx) return handle } - return { ctx, fiber, sources, overlays, mint } + return { ctx, fiber, sources, slots: ctx.slots, mint } } describe('apply', () => { @@ -59,7 +54,7 @@ describe('apply', () => { }) it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { - const { ctx, fiber, sources, overlays } = await bench() + const { ctx, fiber, sources, slots } = await bench() const command = ctx.get('command') expect(command).toBeInstanceOf(CommandService) // Frozen-contract conformance (compile-time check rides the assignment). @@ -67,18 +62,18 @@ describe('apply', () => { expect(typeof contract.register).toBe('function') expect(typeof contract.popupFor).toBe('function') expect([...sources.keys()]).toEqual(['/ command']) - expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup']) + expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup']) await fiber.dispose() expect(sources.size).toBe(0) - expect(overlays.size).toBe(0) + expect(slots.entries('conversation.input.overlay')).toHaveLength(0) }) it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => { - const { ctx, overlays, mint } = await bench() + const { ctx, slots, mint } = await bench() const command = ctx.get('command') as CommandService const scope = mint('s1') - const entry = overlays.get('conversation.input.overlay#command-popup')! - const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected + const entry = slots.entries('conversation.input.overlay')[0]! + const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx)) expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..0df2b4b4df 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: 7bd0d551fc41967326dd9860f5c31a99ea3c254a +README.zh.md: d339f6423d9a9f77c02d86ad0b8e57bd0baba52b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..7bd0d551fc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,21 +6,21 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. -The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. +The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)). @@ -32,13 +32,13 @@ The chat flow projects consecutive model-retry nodes across retry turns into one A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). -Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders. -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -46,9 +46,9 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. -The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. -`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). +`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. ## Model Experience @@ -61,12 +61,12 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. -- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. -- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. +- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. +- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). - **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. +- **The approval panel has no durable grant control** — it supports allow-once and reject only. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. -- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. +- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `user/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..d339f6423d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,19 +6,19 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 -视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 +视图环是一个 slot:会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`),视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface,因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,因此摘要保持有界;面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。 @@ -30,15 +30,15 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 -工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方是只依赖 slot 服务的普通插件:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`。声明本身就是激活与重载依赖;只有调用 `ConversationService` 操作的注册项才需要该服务。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 +`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -46,9 +46,9 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 -`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 +`src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 ## 模型体验 @@ -61,12 +61,12 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 -- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 -- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 +- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 +- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 - **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。 -- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `steering/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 +- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `user/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 88c09b5550..ff4e74da5e 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -72,8 +72,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4f662f3ab6..b796ce84fc 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,6 +1,6 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' -import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -98,20 +98,16 @@ export function apply(ctx: Context): void { const chatStore = createChatStore() const submissionPolicy = new ComposerSubmissionPolicy() - ctx.effect(() => { - const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'composer-enter', - order: 20, - locale: NS, - inject: (): EnterBehaviorRowInjected => ({ - hooks: { busyEnter: submissionPolicy.busyEnter }, - setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) }, - }), - }, EnterBehaviorRow)) - return () => { row.dispose() } - }, 'ui-conversation: Enter behavior settings row') + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'composer-enter', + order: 20, + locale: NS, + inject: (): EnterBehaviorRowInjected => ({ + hooks: { busyEnter: submissionPolicy.busyEnter }, + setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) }, + }), + }, EnterBehaviorRow)) // Chat semantic reader positions by session, surviving view switches and // width reflow when the tab ring remounts the view. Deliberately not @@ -334,17 +330,15 @@ export function apply(ctx: Context): void { }, ChatView) // Session stats stick with the composer (composer.dock = stats-line family). - slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine) + slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine) // Class-plugin mount (packages/AGENTS.md service form): the service // registers itself as `conversation` and lives on its own child fiber. - // Mounted AFTER the chat entry register above — construction guarantee for - // toolview registrants using `inject: ['conversation']` as their load-order - // seam: the service being present implies the chat entry (and with it the - // 'conversation.chat.toolview' declaration) is on the ledger. + // Presentation registrants depend directly on their slot declarations; + // this service remains only where conversation actions are required. ctx.plugin(ConversationService, { input: inputHub }) - // The bash sample rides that exact seam, in third-party posture + // The bash sample rides the same declaration seam, in third-party posture // (ToolRow-matching Bash · {description} chrome). ctx.plugin(bashToolviewSample) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index de882e8bb2..5b3b9fa821 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -30,6 +30,10 @@ export interface AssistantMarkdownProps { /** Turn wall time in ms for the IconActions run-time label; omitted when the * turn's triggering input is outside the loaded window. */ runMs?: number | undefined + /** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */ + ttftMs?: number | undefined + /** Turn decode throughput for the IconActions label; omitted when unrecorded. */ + tokensPerSecond?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ @@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ text={copyText(blocks)} time={time} runMs={runMs} + ttftMs={ttftMs} + tokensPerSecond={tokensPerSecond} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} branchUnavailable={forkUnavailable} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 0830a33e0c..1b5b58ce47 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -16,7 +16,9 @@ flex: 1 1 auto; min-height: 0; overflow-y: auto; - padding: 16px 24px; + /* Sides = composer clearance + 16px: on narrow viewports the transcript + stays exactly 32px narrower than the input card (the shared width rule). */ + padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); } :global([data-conversation-scroll]) .root { @@ -31,10 +33,11 @@ min-height: auto; } -/* Message column: 736px fixed width, centered on the same axis as the - input box; the scroller itself stays full-bleed. */ +/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width), + centered on the same axis as the input box (which caps at chat + 16px); the + scroller itself stays full-bleed. */ .column { - max-width: 736px; + max-width: var(--dsh-chat-content-width); width: 100%; margin: 0 auto; display: flex; @@ -166,7 +169,7 @@ height: 0; display: flex; justify-content: flex-end; - padding-right: max(0px, calc((100% - 736px) / 2)); + padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2)); pointer-events: none; } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3320eb0f69..c852161240 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' import { formatRunDuration } from './message-chrome.ts' +import { deriveTurnMetrics } from './turn-metrics.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -362,6 +363,7 @@ export function ChatView({ const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) + const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) const listRef = useRef<HTMLDivElement | null>(null) const columnRef = useRef<HTMLDivElement | null>(null) @@ -599,6 +601,9 @@ export function ChatView({ const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined + // Metrics gate on the settled in-window timing: turn/start loaded means + // every step of the turn is loaded, so first-step TTFT is genuine. + const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn) return ( <AssistantMarkdown blocks={node.blocks} @@ -608,6 +613,8 @@ export function ChatView({ runMs={timing?.endTime === undefined ? undefined : Math.max(0, timing.endTime - timing.startTime)} + ttftMs={metrics?.ttftMs} + tokensPerSecond={metrics?.tokensPerSecond} seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.module.css b/packages/client/ui-conversation/src/client/chat/ContextBody.module.css new file mode 100644 index 0000000000..c1f6f5361b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.module.css @@ -0,0 +1,161 @@ +/* Expanded context bodies: one code-block surface shared by every form, so the + disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */ + +.text { + margin: 0; + color: var(--dsw-alias-label-secondary); + font: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Provenance beneath the text: dimmer than the content it describes. */ +.fields { + display: flex; + flex-direction: column; + gap: 2px; + margin: 8px 0 0; + padding-top: 8px; + border-top: 1px solid var(--dsw-alias-line-secondary); +} + +.field { + display: flex; + gap: 8px; + min-width: 0; +} + +.fieldKey { + flex: none; + min-width: 96px; + color: var(--dsw-alias-label-caption); +} + +.fieldValue { + flex: 1 1 auto; + min-width: 0; + margin: 0; + color: var(--dsw-alias-label-tertiary); + overflow-wrap: anywhere; +} + +/* instructions: the reconciled files, above their text. */ +.files { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.file { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.filePath { + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +.fileAction { + color: var(--dsw-alias-label-caption); +} + +/* catalog: a replacement notice above one row per published entry. */ +.catalogNotice { + margin: 0 0 6px; + color: var(--dsw-alias-label-caption); +} + + +.entries { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.entry { + display: flex; + gap: 8px; + min-width: 0; +} + +.entryName { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.entryDescription { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* snapshot: one titled block per contributing subsystem. */ +.sections { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; +} + +.section { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.sectionName { + color: var(--dsw-alias-label-caption); +} + +.sectionText { + margin: 0; + color: var(--dsw-alias-label-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* relay: who sent this, above what they said. */ +.relaySender { + margin: 0 0 6px; + color: var(--dsw-alias-label-caption); + overflow-wrap: anywhere; +} + +/* recall: one row per source session, with how much of it survived. */ +.recalls { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.recall { + display: flex; + gap: 8px; + min-width: 0; +} + +.recallLabel { + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +.recallCounts { + flex: none; + color: var(--dsw-alias-label-caption); +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx new file mode 100644 index 0000000000..6af65bcdb9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -0,0 +1,591 @@ +// Expanded bodies for the context disclosure, one per durable context form. +// The producer declares the form; this module only chooses a presentation for +// it. Every form falls back to OpaqueBody, which is the documented default for +// an absent, unknown, or malformed form — a resumed or foreign log must render +// even when this UI version has never seen its producer. + +import type { ReactNode } from 'react' +import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client' +import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import css from './ContextBody.module.css' + +/** Model-facing text stays bounded at the disclosure, not at the producer. */ +const MAX_CHARS = 20_000 + +/** Rows a list body materializes before summarizing the remainder. */ +const MAX_ENTRIES = 200 + +type Translate = ChatViewSlotProps['t'] + +/** One durable source narrowed to the readable-record shape; null for anything else. */ +function asRecord(value: unknown): Record<string, unknown> | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : null +} + +/** One run of the model-facing content: adjacent text, or one unknown block. */ +type ContentRun = { text: string } | { block: unknown } + +/** + * The content blocks as runs, IN THE ORDER the model received them. + * + * Adjacent text blocks join with no separator, matching how provider adapters + * flatten them — inserting a line break would show the reader a line the model + * never saw. An unknown block breaks the run and keeps its own fallback rather + * than being hoisted past the text around it or vanishing; the block union is + * merge-extensible, so a foreign log may interleave shapes this build does not + * know. + */ +function contentRuns(content: ContextMessageNode['content']): ContentRun[] { + const runs: ContentRun[] = [] + for (const block of content) { + if (block.type !== 'text') { + runs.push({ block }) + continue + } + const last = runs[runs.length - 1] + if (last !== undefined && 'text' in last) last.text += block.text + else runs.push({ text: block.text }) + } + return runs +} + +/** Only the blocks this UI version does not know, for bodies that replace the text. */ +function unknownBlocks(content: ContextMessageNode['content']): unknown[] { + return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : []) +} + +/** The model-facing text, truncated to the display bound. */ +function boundedText(text: string, t: Translate): string { + return text.length > MAX_CHARS + ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` + : text +} + +/** + * One source field rendered as a value row; nested shapes stay compact JSON. + * Bounded on its own, because provenance is as unbounded as the text: an unknown + * producer may record an arbitrarily large string or array. + */ +function fieldValue(value: unknown, t: Translate): string { + const text = typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value) + return boundedText(text, t) +} + +/** + * Provenance fields as a key/value list. `kind` is always omitted because the + * row header already names the producer. `form` is omitted only when a + * dedicated body rendered for it — then the presentation the reader is looking + * at IS that value. On the opaque fallback the declaration is kept, because + * that is the one place a form this version cannot present would otherwise + * disappear from the UI entirely. + */ +function SourceFields({ source, formRendered, t }: { + source: unknown + formRendered: boolean + t: Translate +}): ReactNode { + const record = asRecord(source) + if (record === null) return null + const hidden = formRendered ? ['kind', 'form'] : ['kind'] + const rows = Object.entries(record).filter(([key]) => !hidden.includes(key)) + if (rows.length === 0) return null + return ( + <dl className={css.fields} data-context-fields> + {rows.map(([key, value]) => ( + <div key={key} className={css.field}> + <dt className={css.fieldKey}>{key}</dt> + <dd className={css.fieldValue}>{fieldValue(value, t)}</dd> + </div> + ))} + </dl> + ) +} + +/** + * Content blocks this UI version does not know, kept visible rather than + * dropped: the block union is merge-extensible, so a newer or foreign log may + * carry a shape this build has no presentation for. + * @param props - The unrecognized blocks and the locale seat. + * @returns One generic JSON block per unknown entry. + */ +function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode { + return ( + <> + {blocks.map((block, index) => ( + <JsonBlock + key={index} + label={t('message.unknownBlock')} + payload={block} + truncatedLabel={total => t('json.truncated', { total })} + /> + ))} + </> + ) +} + +/** + * The model-facing content of one context, shared by every form that shows it: + * the text with its real line breaks, then any block this UI version does not + * know, which keeps its own fallback rather than vanishing. + * @param props - Durable content and the locale seat. + * @returns The content blocks as the model received them. + */ +function ModelFacingContent({ content, t }: { + content: ContextMessageNode['content'] + t: Translate +}): ReactNode { + return ( + <> + {contentRuns(content).map((run, index) => ('text' in run + ? run.text !== '' && ( + <pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre> + ) + : ( + <JsonBlock + key={index} + label={t('message.unknownBlock')} + payload={run.block} + truncatedLabel={total => t('json.truncated', { total })} + /> + )))} + </> + ) +} + +/** + * Default presentation: the model-facing text as text, with its real line + * breaks, and the remaining provenance beneath it. This is what every form + * this UI version does not recognize renders as. + * @param props - Durable content, its source, and the locale seat. + * @returns The opaque context body. + */ +export function OpaqueBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + return ( + <> + <ModelFacingContent content={content} t={t} /> + <SourceFields source={source} formRendered={false} t={t} /> + </> + ) +} + +/** One reconciled instruction file, as the durable source records it. */ +interface InstructionChange { + action: 'set' | 'replace' | 'remove' + path: string + digest?: string +} + +/** + * Instruction changes read off the source, or null when the record is not a + * usable instruction list. + * + * The read is all-or-nothing: silently dropping one unreadable entry would show + * a confident, incomplete file list for a log this version cannot fully read. + * Paths are deduplicated in first-seen order, matching how the header label is + * derived from the same array. + */ +function instructionChanges(source: unknown): InstructionChange[] | null { + const record = asRecord(source) + const list = record === null ? undefined : record['changes'] + if (!Array.isArray(list)) return null + const changes: InstructionChange[] = [] + const seen = new Set<string>() + for (const entry of list as readonly unknown[]) { + const change = asRecord(entry) + if (change === null) return null + const path = change['path'] + if (typeof path !== 'string' || path === '') return null + const action = change['action'] + // The action decides which word the row shows, so an unrecognized one is + // not a readable change — it would be presented as loaded or updated. + if (action !== 'set' && action !== 'replace' && action !== 'remove') return null + const digest = change['digest'] + if (seen.has(path)) continue + seen.add(path) + changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} }) + } + return changes.length === 0 ? null : changes +} + +/** + * Locale key for one reconciled file. The baseline loads a file; a later delta + * distinguishes a newly reconciled path from a rewritten one, which `set` and + * `replace` already separate at the producer. + * @param action - the durable change action. + * @param baseline - whether this context is the startup/resume baseline. + * @returns the key naming what happened to that file. + */ +function instructionAction( + action: InstructionChange['action'], + baseline: boolean, +): 'message.context.instructions.removed' | 'message.context.instructions.loaded' + | 'message.context.instructions.added' | 'message.context.instructions.updated' { + if (action === 'remove') return 'message.context.instructions.removed' + if (baseline) return 'message.context.instructions.loaded' + return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated' +} + +/** + * `instructions` form: the files this context reconciled, then their text. + * + * The text keeps its `<system-reminder>` framing verbatim — the framing is part + * of what the model read, so hiding it would misreport the request. + * @param props - Durable content, its source, and the locale seat. + * @returns The instructions context body, or the opaque body when the change + * list is unreadable. + */ +export function InstructionsBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const changes = instructionChanges(source) + if (changes === null) return <OpaqueBody content={content} source={source} t={t} /> + const baseline = asRecord(source)?.['baseline'] === true + return ( + <> + <ul className={css.files} data-context-files> + {changes.map(change => ( + <li key={change.path} className={css.file} title={change.digest}> + <span className={css.filePath}>{change.path}</span> + <span className={css.fileAction}> + {t(instructionAction(change.action, baseline))} + </span> + </li> + ))} + </ul> + <ModelFacingContent content={content} t={t} /> + </> + ) +} + +/** One catalog entry, as the durable source records it. */ +interface CatalogEntry { + name: string + description: string +} + +/** + * Catalog entries read off the source, or null when the record is not a usable + * catalog. All-or-nothing for the same reason as the instruction list: this body + * replaces the model-facing text, so a partial list would hide the only complete + * account of what the model read. + */ +function catalogEntries(source: unknown): CatalogEntry[] | null { + const record = asRecord(source) + const list = record === null ? undefined : record['entries'] + if (!Array.isArray(list)) return null + const entries: CatalogEntry[] = [] + for (const item of list as readonly unknown[]) { + const entry = asRecord(item) + if (entry === null) return null + const name = entry['name'] + const description = entry['description'] + if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null + entries.push({ name, description }) + } + // An empty list is a real catalog: a replacement with no entries retires + // every earlier name. Only an unreadable shape falls back. + return entries +} + +/** + * `catalog` form: the published entries as a list, read from the source rather + * than re-parsed out of the model-facing prose. + * + * A catalog whose source carries no usable entries falls through to the opaque + * body, so an older or hand-edited log still shows its text. + * @param props - Durable content, its source, and the locale seat. + * @returns The catalog context body, or the opaque body when the entry list is + * unreadable. + */ +export function CatalogBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const entries = catalogEntries(source) + if (entries === null) return <OpaqueBody content={content} source={source} t={t} /> + const update = asRecord(source)?.['update'] === true + // Entry count is unbounded (a provider may publish any number of skills), and + // the scrollport bounds height, not node count — so the list bounds itself. + const shown = entries.slice(0, MAX_ENTRIES) + const rest = unknownBlocks(content) + return ( + <> + {update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>} + <ul className={css.entries} data-context-entries> + {shown.map((entry, index) => ( + // Index key: a hand-edited or foreign log may repeat a name, and a + // duplicate React key would drop a row the model did see. + <li key={index} className={css.entry}> + <code className={css.entryName}>{entry.name}</code> + <span className={css.entryDescription}>{entry.description}</span> + </li> + ))} + </ul> + {shown.length < entries.length && ( + <p className={css.catalogNotice} data-context-entries-truncated> + {t('message.context.catalog.more', { count: entries.length - shown.length })} + </p> + )} + {/* The block union is merge-extensible: a catalog message carrying an + unknown block still shows it rather than dropping model-visible content. */} + <UnknownBlocks blocks={rest} t={t} /> + </> + ) +} + +/** One named contribution to a runtime snapshot, as the durable source records it. */ +interface SnapshotSection { + name: string + text: string +} + +/** Snapshot sections read off the source, or null when the record is unusable. */ +function snapshotSections(source: unknown): SnapshotSection[] | null { + const record = asRecord(source) + const list = record === null ? undefined : record['sections'] + if (!Array.isArray(list)) return null + const sections: SnapshotSection[] = [] + for (const item of list as readonly unknown[]) { + const section = asRecord(item) + if (section === null) return null + const name = section['name'] + const text = section['text'] + if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null + sections.push({ name, text }) + } + return sections.length === 0 ? null : sections +} + +/** + * `snapshot` form: the named contributions this snapshot assembled, in order. + * + * The sections are the same bytes the model read, split at the boundaries the + * producer assembled them on, so a reader sees which subsystem contributed + * which state instead of one undifferentiated wall. + * + * One sentence of the model-facing text is NOT in any section: the producer's + * framing line declaring that this snapshot supersedes earlier ones. Unlike the + * `<system-reminder>` wrapper an instruction context carries — which wraps + * content and cannot be separated from it — that line states the form's own + * semantics, so the body states them as a caption instead of reprinting the + * joined prose beside the sections it was split from. + * @param props - Durable content, its source, and the locale seat. + * @returns The snapshot context body, or the opaque body when unreadable. + */ +export function SnapshotBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const sections = snapshotSections(source) + /* v8 ignore next -- contextBody reads the sections before choosing this body. */ + if (sections === null) return <OpaqueBody content={content} source={source} t={t} /> + return ( + <> + <p className={css.catalogNotice} data-context-snapshot-supersedes> + {t('message.context.snapshot.supersedes')} + </p> + <dl className={css.sections} data-context-sections> + {sections.map((section, index) => ( + <div key={index} className={css.section}> + <dt className={css.sectionName}>{section.name}</dt> + <dd className={css.sectionText}>{boundedText(section.text, t)}</dd> + </div> + ))} + </dl> + </> + ) +} + +/** + * `notice` form: what just happened, with the model-facing text beneath it. + * + * The one-line account also rides the collapsed row ({@link contextBody}), so a + * notice is usually readable without expanding at all. + * @param props - Durable content, its source, and the locale seat. + * @returns The notice context body. + */ +export function NoticeBody({ content, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + return <ModelFacingContent content={content} t={t} /> +} + +/** + * `relay` form: which agent sent this, then what it said. + * + * The sender is an opaque session id; it is shown as provenance rather than a + * label, because this client cannot resolve it to a title. + * @param props - Durable content, its source, and the locale seat. + * @returns The relay context body. + */ +export function RelayBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const sender = relaySender(source) + /* v8 ignore next -- contextBody resolves the sender before choosing this body. */ + if (sender === null) return <OpaqueBody content={content} source={source} t={t} /> + return ( + <> + <p className={css.relaySender} data-context-relay-sender> + {t('message.context.relay.from', { session: sender })} + </p> + <ModelFacingContent content={content} t={t} /> + </> + ) +} + +/** The sending agent's session id, or null when the record does not name one. */ +function relaySender(source: unknown): string | null { + const sender = asRecord(source)?.['senderSessionId'] + return typeof sender === 'string' && sender !== '' ? sender : null +} + +/** One recalled session, as the durable source records it. */ +interface RecalledSession { + label: string + retained: number + omitted: number + truncated: boolean +} + +/** Recalled sessions read off the source, or null when the record is unusable. */ +function recalledSessions(source: unknown): RecalledSession[] | null { + const record = asRecord(source) + const list = record === null ? undefined : record['references'] + if (!Array.isArray(list)) return null + const sessions: RecalledSession[] = [] + for (const item of list as readonly unknown[]) { + const reference = asRecord(item) + if (reference === null) return null + const label = reference['label'] + const retained = reference['retainedMessages'] + const omitted = reference['omittedMessages'] + const truncated = reference['truncated'] + // Completeness is the fact this card exists to report, so a reference that + // cannot state it is not a readable recall — showing the label alone would + // present a confident card over unknown loss. + if (typeof label !== 'string' || label === '' + || typeof retained !== 'number' || typeof omitted !== 'number' + || typeof truncated !== 'boolean') return null + sessions.push({ label, retained, omitted, truncated }) + } + return sessions.length === 0 ? null : sessions +} + +/** + * `recall` form: which sessions this material came from and how much of each + * survived the read, then the material itself. + * + * Completeness is the fact a reader needs first: recalled context is bounded on + * the way in, so a card that hid the omitted count would overstate what the + * model received. + * @param props - Durable content, its source, and the locale seat. + * @returns The recall context body, or the opaque body when unreadable. + */ +export function RecallBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const sessions = recalledSessions(source) + if (sessions === null) return <OpaqueBody content={content} source={source} t={t} /> + return ( + <> + <ul className={css.recalls} data-context-recalls> + {sessions.map((session, index) => ( + <li key={index} className={css.recall}> + <span className={css.recallLabel}>{session.label}</span> + <span className={css.recallCounts}> + {t('message.context.recall.counts', { + retained: session.retained, + omitted: session.omitted, + })} + </span> + {session.truncated && ( + <span className={css.recallCounts}>{t('message.context.recall.truncated')}</span> + )} + </li> + ))} + </ul> + <ModelFacingContent content={content} t={t} /> + </> + ) +} + +/** The one-line account a `notice` puts on its collapsed row, when it records one. */ +function noticeSummary(source: unknown): string | null { + const summary = asRecord(source)?.['summary'] + return typeof summary === 'string' && summary !== '' ? summary : null +} + +/** + * Choose the body for one context node. + * + * Returns the form the body actually rendered as, which is not always the + * declared one: a declared form whose fields are unreadable falls back to + * opaque, and the caller labels the row with what it really shows. + * `summary` is the collapsed row's one-line account, which only a `notice` + * records: its whole point is being readable without expanding. + * @param form - the producer-declared form projected onto the node. + * @param props - durable content, its source, and the locale seat. + * @returns the rendered form (null for opaque), its collapsed summary, and its body. + */ +export function contextBody( + form: ContextMessageNode['form'], + props: { content: ContextMessageNode['content']; source: unknown; t: Translate }, +): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } { + const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> } + switch (form) { + case 'instructions': + return instructionChanges(props.source) === null + ? opaque + : { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> } + case 'catalog': + return catalogEntries(props.source) === null + ? opaque + : { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> } + case 'snapshot': + return snapshotSections(props.source) === null + ? opaque + : { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> } + case 'notice': { + const summary = noticeSummary(props.source) + return summary === null + ? opaque + : { rendered: 'notice', summary, body: <NoticeBody {...props} /> } + } + case 'relay': + return relaySender(props.source) === null + ? opaque + : { rendered: 'relay', summary: null, body: <RelayBody {...props} /> } + case 'recall': + return recalledSessions(props.source) === null + ? opaque + : { rendered: 'recall', summary: null, body: <RecallBody {...props} /> } + case null: + return opaque + /* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new + KnownContextForm here rather than letting it degrade to opaque silently. */ + default: { + const unreachable: never = form + throw new Error(`unreachable context form: ${String(unreachable)}`) + } + } +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index e603931a27..e72bd594a2 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -12,6 +12,40 @@ color: var(--dsw-alias-label-secondary); } +/* Separator and producer name beside the role title: ToolRow's summary geometry, + so the two disclosure rows keep one 24px rhythm and one separator shape. */ +.sep { + flex: none; + width: 2px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: var(--dsw-alias-label-caption); +} + +.source { + flex: none; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A notice's one-line account: the reason it rarely needs expanding. */ +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + .body { box-sizing: border-box; width: calc(100% - 22px); @@ -23,7 +57,6 @@ border-radius: 8px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-tertiary); + /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */ font: 400 11px/16px var(--ds-font-family-code); - white-space: pre-wrap; - overflow-wrap: anywhere; } diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 4f7bbff348..6cfc1eebfb 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -1,84 +1,70 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { DisclosureRow } from './DisclosureRow.tsx' +import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' -const MAX_CHARS = 20_000 - -function inlineJson(payload: unknown): string { - const raw = JSON.stringify(payload) - let formatted = '' - let quoted = false - let escaped = false - - for (let index = 0; index < raw.length; index++) { - const char = raw.charAt(index) - if (quoted) { - formatted += char - if (escaped) escaped = false - else if (char === '\\') escaped = true - else if (char === '"') quoted = false - continue - } - if (char === '"') { - quoted = true - formatted += char - continue - } - if (char === '{' || char === '[') { - formatted += char - const close = char === '{' ? '}' : ']' - if (raw[index + 1] !== close) formatted += ' ' - continue - } - if (char === '}' || char === ']') { - const open = char === '}' ? '{' : '[' - if (raw[index - 1] !== open) formatted += ' ' - formatted += char - continue - } - formatted += char === ':' || char === ',' ? `${char} ` : char - } - return formatted -} - /** Props for the logged non-user message presentation. */ export interface ContextInjectionRowProps { content: ContextMessageNode['content'] source: ContextMessageNode['source'] + /** Role and producer name projected from the durable source. */ + provenance: ContextMessageNode['provenance'] + /** Producer-declared information form; null renders the opaque body. */ + form: ContextMessageNode['form'] /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } /** * Render logged context with the Tool calls disclosure chrome from Figma. - * @param props - Durable content and source provenance. - * @returns A collapsed context row with a bounded JSON body. + * + * The header names the role the context plays and, beside it, the producer the + * durable source identifies, so a reader can tell an injected skill catalog + * from a workspace instruction file or a recalled session without expanding. + * The expanded body follows the producer-declared form; an absent or unknown + * form renders the opaque body. + * @param props - Durable content, its projected provenance and form, and the locale seat. + * @returns A collapsed context row with a bounded, form-specific body. */ -export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) { +export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) - const body = useMemo(() => { - if (!open) return '' - const text = inlineJson({ content, source }) - return text.length > MAX_CHARS - ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` - : text - }, [content, open, source, t]) + // Resolved rather than declared: a form whose fields are unreadable renders + // the opaque body, and the marker must say what the row actually shows. + const { rendered, summary, body } = contextBody(form, { content, source, t }) return ( <DisclosureRow className={css.root} icon={<IconBrowseOutline16 size={14} />} chevronClassName={css.chevron} - title={t('message.contextInjection')} + title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} + collapsedContent={provenance.label === null ? undefined : ( + /* ToolRow's separator shape: an aria-hidden dot, so the accessible name + stays the two readable parts and the two disclosure rows expose one + name shape. A source that names no producer drops the dot with it. */ + <> + <span className={css.sep} aria-hidden /> + <span className={css.source} data-context-source>{provenance.label}</span> + {summary !== null && ( + <> + <span className={css.sep} aria-hidden /> + <span className={css.summary} data-context-summary>{summary}</span> + </> + )} + </> + )} + keepContentWhenOpen open={open} expandable expandOnRowClick onToggle={() => { setOpen(value => !value) }} > - <pre className={css.body} data-context-injection-body>{body}</pre> + <div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}> + {body} + </div> </DisclosureRow> ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index a2c1459875..99aca83dde 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,12 +1,12 @@ -// Shared IconActions chrome for user, steering, and assistant messages: copy +// Shared IconActions chrome for user and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback, useId } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import { - IconBranchOutline16, IconCopyOutline16, Tooltip, + IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { formatMessageClock, formatRunDuration, writeClipboard } from './message-chrome.ts' +import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts' import { useCalendarDay } from './use-calendar-day.ts' import css from './MessageIconActions.module.css' @@ -17,6 +17,10 @@ export interface MessageIconActionsProps { time?: number | undefined /** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */ runMs?: number | undefined + /** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */ + ttftMs?: number | undefined + /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */ + tokensPerSecond?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ @@ -37,30 +41,74 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, + text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() + // Same success chrome as CodeBlock: a short check swap after the write, + // gated so re-clicks during the window neither re-copy nor stack timers. + const [copied, setCopied] = useState(false) + const copyPending = useRef(false) + const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null) + const copyEpoch = useRef(0) + useEffect(() => () => { + copyEpoch.current += 1 + copyPending.current = false + if (copyTimer.current !== null) clearTimeout(copyTimer.current) + }, []) const onCopy = useCallback(() => { - void writeClipboard(text) - }, [text]) + if (copied || copyPending.current) return + const epoch = copyEpoch.current + copyPending.current = true + void writeClipboard(text).then((ok) => { + if (epoch !== copyEpoch.current) return + copyPending.current = false + if (!ok) return + setCopied(true) + copyTimer.current = window.setTimeout(() => { + copyTimer.current = null + setCopied(false) + }, 1000) + }) + }, [copied, text]) + // The dot is decorative and stays hidden, but its margins separate the + // readings only on screen: without the flanking spaces a reader hears one + // run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts. const clockEl = time === undefined ? null : ( <span className={clock === 'start' ? css.timeStart : css.timeEnd}> {formatMessageClock(time, t, day)} {runMs !== undefined && ( <> + {' '} <span className={css.runTimeDot} aria-hidden>·</span> + {' '} {t('message.ranFor', { duration: formatRunDuration(runMs, t) })} </> )} + {ttftMs !== undefined && ( + <> + {' '} + <span className={css.runTimeDot} aria-hidden>·</span> + {' '} + {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })} + </> + )} + {tokensPerSecond !== undefined && ( + <> + {' '} + <span className={css.runTimeDot} aria-hidden>·</span> + {' '} + {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })} + </> + )} </span> ) return ( <div className={className === undefined ? css.actions : `${css.actions} ${className}`}> {clock === 'start' ? clockEl : null} - <Tooltip label={t('copy')} side="bottom"> - <button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}> - <IconCopyOutline16 /> + <Tooltip label={copied ? t('copied') : t('copy')} side="bottom"> + <button type="button" className={css.action} aria-label={copied ? t('copied') : t('copy')} onClick={onCopy}> + {copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />} </button> </Tooltip> {showBranch && onBranch !== undefined && ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 323c5a5769..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,6 +8,15 @@ gap: 6px; } +/* Steering caption above the bubble: mid-turn interjections carry the same + bubble as a turn-opening prompt, so the transcript names which one this is. */ +.steeringMark { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 16px; +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0bed9a38e..994cd9ca5c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,8 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy / branch IconActions), pending steering -// (copy only), context injection, compaction marker, retry disclosure, and -// unknown-surface JSON rows. +// (right-aligned, with clock + copy / branch IconActions; steering adds the +// interjection caption that names it), pending steering (caption + copy only), +// context injection, compaction marker, retry disclosure, and unknown-surface +// JSON rows. import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' @@ -171,19 +172,22 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, t, + content, actions, pending = false, steering = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ + steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return ( <div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root> + {steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>} <div className={css.bubble}> {projectUserText(text)} {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)} @@ -207,6 +211,7 @@ export function PendingSteeringBubble({ content, t }: { <UserStyleBubble content={content} pending + steering t={t} actions={text => ( <MessageIconActions @@ -231,6 +236,7 @@ export const MessageItem = memo(function MessageItem({ return ( <UserStyleBubble content={node.content} + steering={node.kind === 'steering'} t={t} actions={text => ( <MessageIconActions @@ -247,7 +253,13 @@ export const MessageItem = memo(function MessageItem({ ) case 'context': return ( - <ContextInjectionRow content={node.content} source={node.source} t={t} /> + <ContextInjectionRow + content={node.content} + source={node.source} + provenance={node.provenance} + form={node.form} + t={t} + /> ) case 'compaction': return <CompactionItem node={node} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css index e66afba2c8..c397e7856b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css @@ -1,23 +1,25 @@ /* Session stats row: 12/20 tertiary text under the flow, aligned to the - 736px message column axis. */ + shared message column axis (--dsh-chat-content-width). */ .root { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - max-width: 736px; + /* Block, not flex: text-overflow only elides a block's inline content, so + an overlong line ends in … instead of a mid-glyph clip. */ + display: block; + text-align: center; + max-width: var(--dsh-chat-content-width); width: 100%; margin: 0 auto; box-sizing: border-box; - padding: 4px 24px 0px; + padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } .sep { color: var(--dsw-alias-separator-primary); + margin: 0 10px; /* carries the former flex gap */ } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 4cb5df2565..8e672740cf 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -2,10 +2,14 @@ // Mounted on 'conversation.composer.dock' so it sticks with the composer in the // active conversation scrollport (see ConversationRoot data-conversation-scroll). -import { Fragment, memo, useMemo } from 'react' +import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' +import type { ComposerBarProps } from '../contract/slots.ts' +import { formatTokensPerSecond } from './message-chrome.ts' +import { assistantStepReading } from './turn-metrics.ts' import css from './StatsLine.module.css' interface WindowStats { @@ -15,6 +19,14 @@ interface WindowStats { llmMs: number /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */ toolMs: number + /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */ + ttftMs: number + /** Steps carrying a recorded TTFT. */ + ttftSteps: number + /** Summed decode wall time over steps that also report output tokens. */ + decodeMs: number + /** Summed output tokens over the same decode-timed steps. */ + decodeTokens: number } /** @@ -32,6 +44,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { let steps = 0 let llmMs = 0 let toolMs = 0 + let ttftMs = 0 + let ttftSteps = 0 + let decodeMs = 0 + let decodeTokens = 0 for (const node of nodes) { if (node.kind === 'tool-result') { if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime) @@ -43,8 +59,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { if (node.timing !== undefined && node.timing.stepStartTime !== null) { llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime) } + const reading = assistantStepReading(node) + if (reading.ttftMs !== null) { + ttftMs += reading.ttftMs + ttftSteps += 1 + } + if (reading.decodeMs !== null && reading.outputTokens !== null) { + decodeMs += reading.decodeMs + decodeTokens += reading.outputTokens + } } - return { turns: turns.size, steps, llmMs, toolMs } + return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens } } /** @@ -84,30 +109,41 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null { : Math.round(usage.cacheReadTokens / denominator * 100) } -/** Sum the three disjoint prompt-side billing buckets. */ -function billedInputTokens(usage: TokenUsageProjection): number { +/** + * Sum the three disjoint prompt-side billing buckets. + * @param usage - the session's token-usage projection value. + * @returns billed input tokens. + */ +export function billedInputTokens(usage: TokenUsageProjection): number { return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens } interface ContextOccupancy { percent: number + usedTokens: number contextWindow: number } /** * Approximate context occupancy, using the TUI's integer rounding and upper - * clamp. The numerator and capacity are independent last-wins projection - * fields, so this is a reference figure rather than an exact measurement of one - * request (see the token-meter README). + * clamp. The numerator is `projectedTokens` — the provider sample carried + * forward over the surface's movement since — so compaction shows immediately + * instead of waiting for the next request to report usage; it falls back to the + * bare sample only for a log whose projection predates that field. Numerator + * and capacity remain independent last-wins projection fields, so this is a + * reference figure rather than an exact measurement of one request (see the + * token-meter README). * @param pressure - the session's context-pressure projection value. - * @returns occupancy and its denominator, or null until both values are known. + * @returns occupancy with its numerator and denominator, or null until both values are known. */ export function contextOccupancy( pressure: ContextPressureProjection | undefined, ): ContextOccupancy | null { - if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null + const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens + if (usedTokens === undefined || pressure?.contextWindow === undefined) return null return { - percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)), + percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)), + usedTokens, contextWindow: pressure.contextWindow, } } @@ -116,46 +152,73 @@ export function contextOccupancy( export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> useProjection: UseProjection + /** The owning dock's locale seat. */ + t: ComposerBarProps['t'] } -export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) { +export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { const nodes = useSession(s => s.nodes) const usage = useProjection('tokenUsage') - const pressure = useProjection('contextPressure') const stats = useMemo(() => deriveStats(nodes), [nodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { - groups.push(`${stats.turns} turns · ${stats.steps} steps`) + groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps })) const durations: string[] = [] - if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) - if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) + if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) })) + if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) })) if (durations.length > 0) groups.push(durations.join(' · ')) + // Window-scoped like the wall times above: averages describe loaded steps. + const speeds: string[] = [] + if (stats.ttftSteps > 0) { + speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) })) + } + if (stats.decodeMs > 0) { + speeds.push(t('stats.tokensPerSecond', { + throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)), + })) + } + if (speeds.length > 0) groups.push(speeds.join(' · ')) } - const context = contextOccupancy(pressure) - if (context !== null) { - groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`) - } + // Context occupancy deliberately lives on the composer's ContextMeter ring, + // not here — one home per fact. // Billing rides the durable projection, so these survive paging and // compaction. Suppress the empty projection on a brand-new session. if (usage !== undefined && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { const cacheHit = cacheHitPercent(usage) - if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`) - groups.push( - `Input ${formatTokens(billedInputTokens(usage))} tok` - + ` · Output ${formatTokens(usage.outputTokens)} tok`, - ) + if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit })) + groups.push(t('stats.tokens', { + input: formatTokens(billedInputTokens(usage)), + output: formatTokens(usage.outputTokens), + })) } + const line = groups.join(' | ') + // The row elides with ellipsis when overlong; a delayed hover tooltip carries + // the full line, enabled only while content is actually clipped. + const rootRef = useRef<HTMLDivElement | null>(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const el = rootRef.current + if (el === null) return + const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => { observer.disconnect() } + }, [line]) if (groups.length === 0) return null return ( - <div className={css.root}> - {groups.map((group, i) => ( - <Fragment key={group}> - {i > 0 && <span className={css.sep} aria-hidden>|</span>} - <span>{group}</span> - </Fragment> - ))} - </div> + <Tooltip label={line} side="top" delayMs={500} disabled={!truncated}> + <div ref={rootRef} className={css.root}> + {groups.map((group, i) => ( + <Fragment key={group}> + {i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>} + <span>{group}</span> + </Fragment> + ))} + </div> + </Tooltip> ) }) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 2fc2a82a69..57d2ac1bb0 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -86,8 +86,7 @@ export function messageBranchSeqs( tail = candidate nodeIndex++ } - if (tail?.kind === 'user' - || (tail?.kind === 'steering' && tail.turn === turn) + if (tail?.kind === 'user' || tail?.kind === 'steering' || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { result.add(tail.seq) } diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index a125355a27..a3658853c9 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -1,5 +1,4 @@ -// Shared chrome helpers for user/assistant IconActions rows: clipboard write -// and the compact date+clock label from a session-event epoch. +// Shared time-label helpers for user/assistant IconActions rows. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' @@ -8,46 +7,6 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> /** The elapsed-duration share of the conversation dictionary. */ export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'> - -/** - * Best-effort clipboard write; rejections stay swallowed (no success chrome). - * @param text - Plain text to place on the clipboard. - */ -export async function writeClipboard(text: string): Promise<void> { - // lib.dom types clipboard non-optional, but insecure contexts omit it — - // that runtime gap is exactly what this guard detects. - /* oxlint-disable-next-line typescript/no-unnecessary-condition */ - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text) - } catch { - // Denied permissions / iframe policy. - } - return - } - // execCommand('copy') is the only clipboard fallback where the async API - // is missing (insecure contexts); deprecated but deliberately retained. - /* oxlint-disable typescript/no-deprecated */ - const exec = typeof document.execCommand === 'function' - ? document.execCommand.bind(document) - : undefined - if (exec === undefined) return - const el = document.createElement('textarea') - el.value = text - el.setAttribute('readonly', '') - el.style.position = 'fixed' - el.style.left = '-9999px' - document.body.appendChild(el) - el.select() - try { - exec('copy') - } catch { - // Clipboard unavailable; the button stays idle. - } - /* oxlint-enable typescript/no-deprecated */ - el.remove() -} - function pad2(n: number): string { return String(n).padStart(2, '0') } @@ -89,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string { : t('duration.seconds', { seconds }) } +/** + * Sub-turn latency figure: one decimal under ten seconds, whole seconds + * beyond. Unit-less so the locale template owns the second suffix. + * @param ms - Latency in milliseconds (negatives clamp to zero). + * @returns Display number in seconds without unit. + */ +export function formatLatencySeconds(ms: number): string { + const s = Math.max(0, ms) / 1000 + return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s)) +} + +/** + * Decode-throughput figure: whole tokens from ten up, one decimal below. + * @param tps - Tokens per second. + * @returns Display number without unit. + */ +export function formatTokensPerSecond(tps: number): string { + const clamped = Math.max(0, tps) + return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10) +} + /** * Compact local timestamp for message IconActions. Same calendar day → * `HH:mm`; earlier this year → the `clock.md` date template + clock; other diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts new file mode 100644 index 0000000000..b7cc5ddb72 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts @@ -0,0 +1,97 @@ +// Latency/throughput folds shared by the settled turn footer and StatsLine. + +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' + +/** Latency and decode-throughput readings for one turn's footer. */ +export interface TurnMetrics { + /** First-step TTFT in ms; absent when that step carries no recorded timing. */ + ttftMs?: number + /** Decode throughput over steps carrying both timing and provider usage. */ + tokensPerSecond?: number +} + +/** One assistant step's derivable latency facts; null marks an unrecorded part. */ +export interface StepReading { + /** step/start → first token delta, in ms. */ + ttftMs: number | null + /** First token delta → final message, in ms. */ + decodeMs: number | null + /** Provider-reported completion tokens. */ + outputTokens: number | null +} + +interface UsageLike { + outputTokens?: number +} + +type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }> + +function usageOutputTokens(usage: unknown): number | null { + if (typeof usage !== 'object' || usage === null) return null + const value = (usage as UsageLike).outputTokens + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null +} + +/** + * Read one assistant node's TTFT, decode wall time, and output tokens. + * @param node - A settled assistant node. + * @returns Per-part readings with `null` for unrecorded values. + */ +export function assistantStepReading(node: AssistantNode): StepReading { + const timing = node.timing + const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null + ? Math.max(0, timing.firstTokenTime - timing.stepStartTime) + : null + const decodeMs = timing !== undefined && timing.firstTokenTime !== null + ? Math.max(0, timing.completedTime - timing.firstTokenTime) + : null + return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) } +} + +interface TurnFold { + firstStep: number + firstStepTtftMs: number | null + decodeMs: number + outputTokens: number + sampled: boolean +} + +/** + * Fold assistant nodes into per-turn footer metrics. + * + * TTFT is the turn's lowest-step request-dispatch-to-first-token reading, so + * it is only meaningful when the turn's start is inside + * the loaded window (the caller gates on `turnTimings`, which shares that + * window). Throughput divides summed output tokens by summed decode wall time, + * counting only steps that carry both. + * @param nodes - Snapshot nodes of the loaded window. + * @returns Turn number → available metrics; turns with none are absent. + */ +export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> { + const folds = new Map<number, TurnFold>() + for (const node of nodes) { + if (node.kind !== 'assistant') continue + const reading = assistantStepReading(node) + let fold = folds.get(node.turn) + if (fold === undefined) { + fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false } + folds.set(node.turn, fold) + } else if (node.step < fold.firstStep) { + fold.firstStep = node.step + fold.firstStepTtftMs = reading.ttftMs + } + if (reading.decodeMs !== null && reading.outputTokens !== null) { + fold.decodeMs += reading.decodeMs + fold.outputTokens += reading.outputTokens + fold.sampled = true + } + } + const metrics = new Map<number, TurnMetrics>() + for (const [turn, fold] of folds) { + const entry: TurnMetrics = {} + if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs + if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000) + if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry) + } + return metrics +} diff --git a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts index bc914e4820..ec19f1cce0 100644 --- a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -76,11 +76,8 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null { * * This derivation consumes only `diffs`; the render intent's `title` field is * deliberately dropped. The row supplies its own title (`Edit`/`Write · path` - * from the args) and that outranks the view's `title`, matching the TUI diff - * branch, which likewise draws no view title. A tool that names its own diff - * header therefore does not surface that text on the Web row — an accepted - * product choice, recorded here as the one asymmetry with the terminal card, - * whose derivation does consume the view's title. + * from the args), which outranks the view's `title`. A tool that names its own + * diff header therefore does not surface that text on the Web row. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the diff-card props, or null for the generic path. */ diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index f6c7f5a911..8a0c887990 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -1,6 +1,6 @@ /** * Pure derivation of the terminal-card props from a frozen call slice: the - * `card:'terminal'` render intent the bash tool declares arrives on the + * `card:'terminal'` render intent the shell tools declare arrives on the * snapshot as `callView`/`resultView`, and this is the one place that turns * that pair into what {@link TerminalBlock} draws. Both conversation render * sites (the chat tool row's expanded body and the details panel's Output diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 41688c3de9..9ae5c4c85b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -31,6 +31,9 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = { /** Known tool name -> variant. */ const TOOL_VARIANTS: Record<string, ToolRowVariant> = { bash: 'bash', + // The PowerShell twin is a shell tool: the bash row family (icon, colors) + // with its own title from TOOL_TITLES, not the generic `others` row. + pwsh: 'bash', read: 'read', web_fetch: 'read', web_search: 'search', @@ -49,6 +52,7 @@ const TOOL_TITLES: Record<string, string> = { cordis_inspect: 'Inspect', cordis_mount: 'Mount temporary Plugin', cordis_unmount: 'Unmount temporary Plugin', + pwsh: 'Pwsh', } /** diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 2f05223e5d..9ba5ed3876 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,18 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'context.aria': '上下文已用 {percent}', + 'context.used': '上下文已用', + 'context.system': '系统提示词', + 'context.tools': '工具', + 'context.messages': '对话消息', + 'stats.counts': '{turns} 轮 · {steps} 步', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': '工具调用 {duration}', + 'stats.ttftAverage': '首 token 平均 {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': '缓存命中 {percent}%', + 'stats.tokens': '输入 {input} tok · 输出 {output} tok', 'settings.enter.title': '繁忙时 Enter 键行为', 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', 'settings.enter.queue': '排队发送', @@ -43,8 +55,10 @@ export const zh = { 'details.input': '输入', 'details.output': '输出', 'details.running': '运行中…', - 'todo.title': '任务清单', - 'todo.progress': '{done}/{total} 项任务 · {active} 项进行中', + 'todo.title': '任务', + 'todo.progress.done': '{done} 已完成', + 'todo.progress.active': '{active} 进行中', + 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', 'chat.loadingHistory': '载入历史…', @@ -53,6 +67,18 @@ export const zh = { 'chat.toBottom': '回到底部', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', + 'message.contextRecall': '跨会话召回', + 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.added': '已新增', + 'message.context.instructions.updated': '已更新', + 'message.context.instructions.removed': '已移除', + 'message.context.catalog.replaced': '替换目录', + 'message.context.catalog.more': '…还有 {count} 条', + 'message.context.snapshot.supersedes': '取代先前的快照', + 'message.context.relay.from': '来自会话 {session}', + 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', + 'message.context.recall.truncated': '已截断', + 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -70,6 +96,8 @@ export const zh = { 'message.retry.failure': '失败原因:', 'message.turnError': '本轮运行失败', 'message.ranFor': '用时 {duration}', + 'message.ttft': '首 token {seconds}秒', + 'message.tokensPerSecond': '{tps} tok/s', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', @@ -135,6 +163,18 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'context.aria': '{percent} of context used', + 'context.used': 'of context used', + 'context.system': 'System prompt', + 'context.tools': 'Tools', + 'context.messages': 'Messages', + 'stats.counts': '{turns} turns · {steps} steps', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': 'Tool call {duration}', + 'stats.ttftAverage': 'TTFT avg {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': 'Cache hit {percent}%', + 'stats.tokens': 'Input {input} tok · Output {output} tok', 'settings.enter.title': 'Enter behavior while busy', 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', 'settings.enter.queue': 'Queue', @@ -156,7 +196,9 @@ export const en = { 'details.output': 'Output', 'details.running': 'Running…', 'todo.title': 'To-dos', - 'todo.progress': '{done}/{total} tasks · {active} in progress', + 'todo.progress.done': '{done} completed', + 'todo.progress.active': '{active} in progress', + 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', 'chat.loadingHistory': 'Loading history…', @@ -165,6 +207,18 @@ export const en = { 'chat.toBottom': 'Back to bottom', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', + 'message.contextRecall': 'Session recall', + 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.added': 'added', + 'message.context.instructions.updated': 'updated', + 'message.context.instructions.removed': 'removed', + 'message.context.catalog.replaced': 'Replacement catalog', + 'message.context.catalog.more': '… {count} more', + 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', + 'message.context.relay.from': 'From session {session}', + 'message.context.recall.counts': '{retained} kept · {omitted} omitted', + 'message.context.recall.truncated': 'truncated', + 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', @@ -182,6 +236,8 @@ export const en = { 'message.retry.failure': 'Failure reason: ', 'message.turnError': 'This turn failed', 'message.ranFor': 'Ran for {duration}', + 'message.ttft': 'TTFT {seconds}s', + 'message.tokensPerSecond': '{tps} tok/s', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'command.running': 'Running…', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 5bdaa43c51..eca51941ca 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -16,20 +16,21 @@ var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) ); - /* Flex gap still applies after this item; subtract it together with the - design's overlap so the later composer paints over the queue edge. */ - margin: 0 auto calc( - 0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap) - ); - padding: 2px 12px; + /* Cancel the stack gap after this item and tuck 3px under the input card + (square bottom), reading as one attached surface. */ + margin: 0 auto calc(0px - var(--dsh-composer-stack-gap) - 3px); + /* Horizontal padding completes the shared dock inset (this wrapper only + subtracts two insets from its width); no vertical padding, so the visual + gap above the panel stays the uniform stack gap. */ + padding: 0 var(--dsh-composer-dock-inset); } .panel { position: relative; overflow: hidden; width: 100%; - padding-top: 2px; - border-radius: 14px 14px 0 0; + padding: 2px 0; + border-radius: 12px 12px 0 0; background: var(--dsw-specific-tip); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); @@ -39,6 +40,7 @@ position: absolute; inset: 0; border: 1px solid var(--dsw-alias-border-l1); + /* The input card's own top border closes the shape below. */ border-bottom: none; border-radius: inherit; content: ''; @@ -52,7 +54,9 @@ gap: 10px; width: 100%; height: 36px; - padding: 4px 16px 4px 12px; + /* Right inset 12px puts the chevron on the same vertical line as the Todo + header's chevron (12px body padding there). */ + padding: 4px 12px; border: none; border-radius: 8px; background: transparent; @@ -70,11 +74,18 @@ cursor: default; } +.lead { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + .count { flex: 1 1 auto; min-width: 0; font-family: Inter, var(--dsw-font-family); - font-size: 14px; + font-size: 13px; font-weight: 500; line-height: 24px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 67d6153519..8a301d03ff 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -8,8 +8,8 @@ import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, - IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16, + IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, + IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -87,6 +87,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > + <span className={css.lead} aria-hidden><IconQueueOutline14 /></span> <span className={css.count}>{t('queue.count', { n: queue.length })}</span> <span className={css.chevron} aria-hidden> {expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />} @@ -96,6 +97,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps <ul id={listId} className={css.list} hidden={!listVisible}> {listVisible && queue.map(row => ( <li key={row.id} className={css.row}> + {/* Single-item strip has no count header, so the row itself carries the queue glyph. */} + {queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>} {editing?.id === row.id ? ( <input @@ -121,74 +124,83 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps {editing?.id === row.id ? ( <> - <button - type="button" - className={css.action} - aria-label={t('queue.save')} - title={t('queue.save')} - disabled={busy !== null || editing.text.trim() === ''} - onClick={() => { void saveEdit() }} - > - <IconCheckOutline16 size={14} /> - </button> - <button - type="button" - className={css.action} - aria-label={t('queue.cancelEdit')} - title={t('queue.cancelEdit')} - disabled={busy !== null} - onClick={() => { setEditing(null) }} - > - <IconCloseOutline16 size={14} /> - </button> + <Tooltip label={t('queue.save')} side="bottom" delayMs={500}> + <button + type="button" + className={css.action} + aria-label={t('queue.save')} + disabled={busy !== null || editing.text.trim() === ''} + onClick={() => { void saveEdit() }} + > + <IconCheckOutline16 size={14} /> + </button> + </Tooltip> + <Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}> + <button + type="button" + className={css.action} + aria-label={t('queue.cancelEdit')} + disabled={busy !== null} + onClick={() => { setEditing(null) }} + > + <IconCloseOutline16 size={14} /> + </button> + </Tooltip> </> ) : ( <> - <button - type="button" - className={css.action} - aria-label={t('queue.edit')} - title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')} - disabled={busy !== null || row.text === null} - onClick={() => { - if (row.text !== null) setEditing({ id: row.id, text: row.text }) - }} - > - <IconEditOutline16 size={14} /> - </button> - <button - type="button" - className={css.action} - aria-label={t('queue.remove')} - title={t('queue.remove')} - disabled={busy !== null} - onClick={() => { - void applyAction( - row.id, - { kind: 'remove' }, - t('queue.removeFailed'), - ) - }} - > - <IconTrashOutline16 size={14} /> - </button> - <button - type="button" - className={css.action} - aria-label={t('queue.steer')} - title={running ? t('queue.steer') : t('queue.steer.unavailable')} - disabled={busy !== null || !running} - onClick={() => { - void applyAction( - row.id, - { kind: 'steer' }, - t('queue.steerFailed'), - ) - }} - > - <IconSendOutline16 size={14} /> - </button> + <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}> + <button + type="button" + className={css.action} + aria-label={t('queue.edit')} + // Disabled buttons fire no hover events, so the + // unsupported hint stays a native title. + title={row.text === null ? t('queue.edit.unsupported') : undefined} + disabled={busy !== null || row.text === null} + onClick={() => { + if (row.text !== null) setEditing({ id: row.id, text: row.text }) + }} + > + <IconEditOutline16 size={14} /> + </button> + </Tooltip> + <Tooltip label={t('queue.remove')} side="bottom" delayMs={500}> + <button + type="button" + className={css.action} + aria-label={t('queue.remove')} + disabled={busy !== null} + onClick={() => { + void applyAction( + row.id, + { kind: 'remove' }, + t('queue.removeFailed'), + ) + }} + > + <IconTrashOutline16 size={14} /> + </button> + </Tooltip> + <Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}> + <button + type="button" + className={css.action} + aria-label={t('queue.steer')} + title={running ? undefined : t('queue.steer.unavailable')} + disabled={busy !== null || !running} + onClick={() => { + void applyAction( + row.id, + { kind: 'steer' }, + t('queue.steerFailed'), + ) + }} + > + <IconSendOutline14 /> + </button> + </Tooltip> </> )} </div>} @@ -201,8 +213,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps } /** - * The dock entry as a plain registrant plugin. The conversation service is the - * ordering and action seam; session scopes provide the exact queue owner. + * The dock entry as a plain registrant plugin. The conversation service is + * the action seam; the slot declaration is its independent lifecycle seam. */ export const queueDockEntry = { name: 'conversation-queue-dock', @@ -212,7 +224,7 @@ export const queueDockEntry = { * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ + ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 20, @@ -227,6 +239,6 @@ export const queueDockEntry = { notify: (level, text) => { conversation.input.for(actx).notify(level, text) }, } }, - }, QueueDock) + }, QueueDock)) }, } diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css index 872620092f..87cdc07a0c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -8,13 +8,15 @@ display: flex; flex-direction: column; align-items: center; - padding: 8px 32px 12px; + /* Sides = clearance + 16px so the card lands on the shared content width + (input card - 32) at every viewport. */ + padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; } .card { overflow: hidden; width: 100%; - max-width: 776px; + max-width: var(--dsh-chat-content-width); border: 1px solid var(--dsw-alias-state-warn-secondary); border-radius: 20px; background: var(--dsw-specific-input-major); @@ -84,7 +86,9 @@ /* Card-level row, not body content. Its padding reproduces the metrics the row had inside the body: 14px above (the flex gap of 6 plus the row's 8px top margin, neither of which reaches it out here) and the body's former 14px - bottom pad below, so the resting card is unchanged. */ + bottom pad below, so the resting card is unchanged. Buttons are the shared + outline/primary capsules (Button atom, matching QuestionComposer's footer); + only the reject's danger hover is local. */ .actionRow { display: flex; justify-content: flex-end; @@ -92,36 +96,6 @@ padding: 14px 16px 14px; } -.allow, -.reject { - padding: 6px 16px; - border-radius: 10px; - font-size: 13px; - line-height: 18px; - cursor: pointer; -} - -.allow:disabled, -.reject:disabled { - opacity: 0.5; - cursor: default; -} - -/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped - always-allow button). */ -.allow { - border: none; - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-label-primary-foreground); -} - -/* Secondary: quiet outline. */ -.reject { - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - background: transparent; - color: var(--dsw-alias-label-secondary); -} - .reject:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index 5bd2008c35..a1a0a120f5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -14,6 +14,7 @@ // grant storage. import { useMemo, useState } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -69,12 +70,12 @@ function ApprovalFlow({ pending, command, t }: { {command !== undefined && <div className={css.command}>{command}</div>} </div> <div className={css.actionRow}> - <button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}> + <Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}> {t('approval.reject')} - </button> - <button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}> + </Button> + <Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}> {t('approval.allowOnce')} - </button> + </Button> </div> </div> </div> diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css new file mode 100644 index 0000000000..9eed743214 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -0,0 +1,147 @@ +/* Context-occupancy ring beside the send button plus its click-open breakdown + panel (menu surface: r12, inverted hairline, shadow-lv3). */ + +.root { + position: relative; + display: inline-flex; +} + +/* Same 28px circular hit target family as the composer's attach button. */ +.trigger { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.track { + fill: none; + stroke: var(--dsw-alias-border-l3); + stroke-width: 2; +} + +.fill { + fill: none; + stroke: var(--dsw-alias-label-tertiary); + stroke-width: 2; + stroke-linecap: round; +} + +.panel { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 100; + box-sizing: border-box; + width: 264px; + padding: 12px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + cursor: default; +} + +.header { + display: flex; + align-items: center; + gap: 6px; +} + +.figures { + margin-left: auto; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} + +.percent { + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.headline { + color: var(--dsw-alias-label-tertiary); +} + +/* The headline brackets the reading, so the side a locale leaves empty must + drop out of the flex row rather than spend a gap. */ +.headline:empty { + display: none; +} + +.bar { + display: flex; + gap: 1px; + margin: 10px 0 12px; + height: 4px; + border-radius: 999px; + background: var(--dsw-alias-interactive-bg-hover); + overflow: hidden; +} + +.segment { + flex: none; + min-width: 2px; + height: 100%; + border-radius: 1px; + background: var(--meter-tint, var(--dsw-alias-label-tertiary)); +} + +.swatch { + display: inline-block; + margin-right: 6px; + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--meter-tint); + vertical-align: baseline; +} + +.colorSystem { + --meter-tint: var(--dsw-static-neutral-bluish-400); +} + +.colorTools { + /* The design platform ships no purple static token; violet-400 literal. */ + --meter-tint: rgb(167, 139, 250); +} + +.colorMessages { + --meter-tint: var(--dsw-static-blue-450); +} + +.rows { + margin: 6px 0 0; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 0; +} + +.row dt { + color: var(--dsw-alias-label-secondary); +} + +.row dd { + margin: 0; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx new file mode 100644 index 0000000000..1f0bedded7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -0,0 +1,153 @@ +/** Composer context-occupancy meter: a ring beside the send button fed by the + * `contextPressure` projection, with a click-open panel of the heuristic + * `contextBreakdown` composition (system prompt, tools, conversation). + * Renders nothing until a provider reports both pressure and a route capacity + * (same gate as the stats row used). */ + +import { useEffect, useRef, useState } from 'react' +import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the `contextPressure` / `contextBreakdown` projection key merges. +import type {} from '@deepseek-ai/dsh-token-meter/client' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ComposerBarProps } from '../contract/slots.ts' +import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx' +import css from './ContextMeter.module.css' + +/** Ring geometry: 14px viewBox, 2px stroke. */ +const RADIUS = 5.5 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +/** + * Marker the localized occupancy sentence is split on, so the panel headline + * keeps the reading in its own tone while each locale still owns the word + * order (`45% of context used` / `上下文已用 45%`). + */ +const READING_SLOT = '\u0000' + +/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */ +const ROWS = [ + { key: 'systemTokens', label: 'context.system', color: css.colorSystem }, + { key: 'toolsTokens', label: 'context.tools', color: css.colorTools }, + { key: 'messageTokens', label: 'context.messages', color: css.colorMessages }, +] as const + +export interface ContextMeterProps { + useProjection: UseProjection + /** The owning bar's locale seat, passed down as a plain prop. */ + t: ComposerBarProps['t'] +} + +export function ContextMeter({ useProjection, t }: ContextMeterProps) { + const pressure = useProjection('contextPressure') + const breakdown = useProjection('contextBreakdown') + const [open, setOpen] = useState(false) + const rootRef = useRef<HTMLSpanElement | null>(null) + const context = contextOccupancy(pressure) + const available = context !== null + + // A model switch can temporarily remove capacity while this component stays + // mounted. Close the now-unavailable panel instead of preserving stale UI. + useEffect(() => { + if (!available && open) setOpen(false) + }, [available, open]) + + // Outside click / Escape close, one document listener while open (Menu's pattern). + useEffect(() => { + if (!open || !available) return + const onPointerDown = (e: PointerEvent): void => { + if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return + setOpen(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [available, open]) + + if (context === null) return null + const percent = context.percent + const reading = `${percent}%` + const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT }) + .split(READING_SLOT) + .map(part => part.trim()) + + // The bar's overall length stays the provider-exact percent; the heuristic + // breakdown only proportions its colored parts. A zero-width part is dropped + // instead of rendered: `.segment`'s min-width keeps a hairline part visible, + // which at 0% occupancy would draw a filled bar over an empty context. + const breakdownTotal = breakdown === undefined + ? 0 + : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens + const parts = breakdown === undefined || breakdownTotal === 0 + ? [{ key: 'total', color: undefined, width: percent }] + : ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal })) + const segments = parts.filter(part => part.width > 0) + + return ( + <span ref={rootRef} className={css.root}> + <Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}> + <button + type="button" + className={css.trigger} + aria-label={t('context.aria', { percent: reading })} + aria-haspopup="dialog" + aria-expanded={open} + onClick={() => { setOpen(!open) }} + > + <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden> + <circle className={css.track} cx="7" cy="7" r={RADIUS} /> + <circle + className={css.fill} + cx="7" + cy="7" + r={RADIUS} + strokeDasharray={`${CIRCUMFERENCE * percent / 100} ${CIRCUMFERENCE}`} + transform="rotate(-90 7 7)" + /> + </svg> + </button> + </Tooltip> + {open && ( + <div className={css.panel} role="dialog" aria-label={t('context.used')}> + <div className={css.header}> + {/* Empty sides collapse through `.headline:empty` so the locale that + needs no leading (or trailing) text spends no header gap. */} + <span className={css.headline}>{headBefore}</span> + <span className={css.percent}>{reading}</span> + <span className={css.headline}>{headAfter}</span> + <span className={css.figures}> + {`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`} + </span> + </div> + <div className={css.bar}> + {segments.map(segment => ( + <div + key={segment.key} + className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`} + style={{ width: `${segment.width}%` }} + /> + ))} + </div> + {breakdown !== undefined && ( + <dl className={css.rows}> + {ROWS.map(row => ( + <div key={row.key} className={css.row}> + <dt> + <span className={`${css.swatch} ${row.color}`} aria-hidden /> + {t(row.label)} + </dt> + <dd>{`~${formatTokens(breakdown[row.key])}`}</dd> + </div> + ))} + </dl> + )} + </div> + )} + </span> + ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1a83efb551..b1c5f51451 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -9,6 +9,20 @@ height: 100%; min-width: 0; background: var(--dsw-alias-bg-base); + + /* Shared width axis for the whole column: one content width W + (--dsh-chat-content-width) for the transcript, the dock cards + (todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover + cards (question/approval/plan review); the input card alone is W + 32px. + The relation also holds when a narrow viewport shrinks everything: the + chat scroller and the takeover frames pad clearance + 16px per side while + the input card clears the bare clearance, so the input card stays exactly + content + 32px at every width. Declared on the root because the + transcript and the composer seat are sibling subtrees. */ + --dsh-chat-content-width: 748px; + --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); + --dsh-composer-side-clearance: 16px; + --dsh-composer-dock-inset: 8px; } .header { @@ -134,16 +148,11 @@ } /* Composer context stack (Figma 9:937): standalone dock cards share one - rhythm; the terminal queue strip additionally tucks under the input card. */ + rhythm above the input card. */ .composerStack { + /* Horizontal geometry (card width, clearance, dock inset) rides the shared + .root variables above so takeover siblings match the stack. */ --dsh-composer-stack-gap: 6px; - --dsh-queue-composer-overlap: 5px; - - /* InputBar and dock registrants derive their horizontal geometry from the - same card width, outer clearance, and dock inset. */ - --dsh-composer-card-max-width: 800px; - --dsh-composer-side-clearance: 32px; - --dsh-composer-dock-inset: 12px; display: flex; flex-direction: column; @@ -182,6 +191,11 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* Reserved unconditionally: the composer seat rides this box's content box in + Chat and its padding box under a view's composer overlay, so an `auto` + gutter moves the input card sideways by the bar's width whenever the two + differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ + scrollbar-gutter: stable; } .root[data-phase='active'] .viewArea { @@ -212,7 +226,13 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - overflow: hidden; + /* A clipping box nothing scrolls out of, stated as a scroll container on both + axes rather than `overflow: hidden`: WebKit honours the reservation above + only in the `overflow-y: auto` form, and a single-axis scroller computes + the other axis to `auto` + ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ + overflow-x: hidden; + overflow-y: auto; } .scrollBody:has([data-conversation-composer-overlay]) > .viewArea { @@ -239,7 +259,9 @@ gap: 12px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; - width: min(776px, calc(100% - 48px)); + /* Card cap + both clearances: the hero input card lands at exactly the same + width as the docked composer at every viewport. */ + width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%); z-index: 1; } @@ -263,7 +285,9 @@ display: flex; align-items: center; min-width: 0; - padding-left: 8px; + /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to + the card's inner controls below. */ + padding-left: 20px; } /* Hero: the composer sits inside the session scroll body; center there so diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index b8926c1c47..dce9416831 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -145,7 +145,7 @@ export function ConversationRoot({ {hero && <HeroGlow className={css.heroGlow} />} {hero && <HeroShell t={t} />} {hero && heroWorkspaceRow} - {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} + {zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} </div> ) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8dc96c1bd6..6ab387c3eb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -11,23 +11,28 @@ /* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action row below, one primary circle button bottom-right. Input width rides the - column (800 is a cap, not a fixed size — layout rule: the box shrinks with - the center column keeping its padding). Hero variant = the same card - centered in the empty state; the transition between the two is a position - move of one component. */ + column (--dsh-composer-card-max-width = chat content + 32px, 16px per side, + is a cap, not a fixed size — layout rule: the box shrinks with the center + column keeping its clearance). Hero variant = the same card centered in the + empty state; the transition between the two is a position move of one + component. */ .root { display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by - the chat scroller. No top pad: the composer stack's gap owns the space - above; error/status strips still carry their own margin. */ + /* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8; + the sides narrow with the shared width axis); the bottom gradient mask + is owned by the chat scroller. No top pad: the composer stack's gap owns + the space above; error/status strips still carry their own margin. */ padding: 0 var(--dsh-composer-side-clearance) 8px; } .hero { - padding: 0; + /* No bottom pad in the centered hero, but the side clearance must survive: + the hero wrapper is full-width on narrow viewports, so this padding is + the only thing keeping the card off the edges there. */ + padding: 0 var(--dsh-composer-side-clearance); } .error, @@ -83,7 +88,7 @@ the input border is one notch weaker than buttons) — exactly the l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */ border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 20px; + border-radius: 22px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv2); font-size: 16px; @@ -256,8 +261,16 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 0 10px 10px 10px; + /* 2px moved from the bottom pad to the top: the whole control row sits 2px + lower in the card (it read too high against the textarea) while the card + height and the controls' own centering stay untouched. */ + padding: 2px 8px 6px; min-width: 0; + /* Size container so the chips inside can collapse to icon-only when the + card runs out of row width (PermissionSelect @container rule). Anonymous + on purpose: CSS modules hash container-name per module, so a name declared + here can never match a query in another module's sheet. */ + container-type: inline-size; } .tools, @@ -268,13 +281,15 @@ min-width: 0; } -/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */ +/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan / + Read-only; the chip gap widened to 12 so the pill chips read as separate + controls. */ .tools { gap: 16px; } .modes { - gap: 4px; + gap: 12px; } .trailing { @@ -354,6 +369,10 @@ color: #fff; cursor: pointer; transition: background-color 100ms ease; + /* Opts out of the row's 2px downward shift (.row top pad): the send circle + keeps its original seat while the smaller chips sit lower. Transform, not + margin, so flex centering math is untouched. */ + transform: translateY(-2px); } .primary:hover:not(:disabled) { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 72c7638c89..131f63c49d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' +import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' @@ -489,19 +490,20 @@ export function InputBar({ </div> <div className={css.row}> <div className={css.tools}> - <button - type="button" - className={css.add} - aria-label={t('input.commands')} - title={t('input.commands')} - aria-haspopup="listbox" - aria-expanded={commandMenuOpen} - disabled={locked || toggleCommandMenu === undefined} - onMouseDown={keepFocus} - onClick={onToggleCommandMenu} - > - <IconPlusOutline16 size={14} /> - </button> + <Tooltip label={t('input.commands')} side="top" delayMs={500}> + <button + type="button" + className={css.add} + aria-label={t('input.commands')} + aria-haspopup="listbox" + aria-expanded={commandMenuOpen} + disabled={locked || toggleCommandMenu === undefined} + onMouseDown={keepFocus} + onClick={onToggleCommandMenu} + > + <IconPlusOutline16 size={14} /> + </button> + </Tooltip> <div className={css.modes}> {accessSelect} {renderSlot('conversation.input.plan', { locked })} @@ -511,26 +513,28 @@ export function InputBar({ <div className={css.trailing}> {rightItems} {renderSlot('conversation.input.model', { locked })} + <ContextMeter useProjection={useProjection} t={t} /> {/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */} - <button - type="button" - className={css.primary} - aria-label={primaryLabel} - title={primaryLabel} - disabled={stopping ? stop === undefined : empty || disabled || machineBusy} - onMouseDown={keepFocus} - onClick={onPrimary} - > - {stopping ? ( - <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> - <rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" /> - </svg> - ) : ( - <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> - <path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" /> - </svg> - )} - </button> + <Tooltip label={primaryLabel} side="top" delayMs={500}> + <button + type="button" + className={css.primary} + aria-label={primaryLabel} + disabled={stopping ? stop === undefined : empty || disabled || machineBusy} + onMouseDown={keepFocus} + onClick={onPrimary} + > + {stopping ? ( + <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> + <rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" /> + </svg> + ) : ( + <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> + <path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" /> + </svg> + )} + </button> + </Tooltip> </div> </div> </div> diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 50dce3913f..60aceaa120 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -7,7 +7,8 @@ height: 28px; padding: 0 4px 0 8px; border: none; - border-radius: 8px; + /* Rounded chip chrome, matching the sibling model trigger. */ + border-radius: 24px; outline: none; background: transparent; color: var(--dsw-alias-label-secondary); @@ -30,6 +31,18 @@ cursor: default; } +.triggerIcon { + display: inline-flex; + flex: 0 0 auto; +} + +/* The shared 16px glyphs render one step smaller on the exposed trigger; + the dropdown rows keep the full 16px. */ +.triggerIcon svg { + width: 14px; + height: 14px; +} + .triggerLabel { min-width: 0; overflow: hidden; @@ -40,4 +53,21 @@ .chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); + transition: transform 120ms ease; +} + +/* Narrow composer: the trigger collapses to icon + chevron so the row keeps + fitting. Only triggers that actually carry a mode glyph drop their label — + a host-configured mode without one keeps its text as the sole identifier. + The 460px cut is the point where the row (attach + modes + model + send) + starts squeezing labels; the container is the composer row (InputBar .row — + anonymous query because CSS modules hash container-names per module). */ +@container (max-width: 460px) { + .trigger:has(.triggerIcon) .triggerLabel { + display: none; + } +} + +.chevronOpen { + transform: rotate(180deg); } diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 16974880dd..73f4080c1c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,12 +1,50 @@ import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import css from './PermissionSelect.module.css' const FULL_ACCESS = 'danger-full-access' +/* Shield glyphs (design set 1556): check = read-only, pencil = workspace + write, exclamation = full access. currentColor so the trigger and menu + rows tint them with their own text color. */ + +const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' + +const permissionGlyphs = { + 'read-only': ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden> + <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" /> + <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" /> + </svg> + ), + 'workspace-write': ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden> + <path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" /> + <path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" /> + <path d="M9.5824 8.29376V9.50376H5V8.29376H9.5824Z" fill="currentColor" /> + <path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" /> + <path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" /> + </svg> + ), + [FULL_ACCESS]: ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden> + <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" /> + <path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" /> + <path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" /> + </svg> + ), +} as Record<string, ReactNode> + +/** Glyph for a permission option value; host-configured names outside the design set get none. */ +function permissionGlyph(value: string): ReactNode | undefined { + return permissionGlyphs[value] +} + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names @@ -52,7 +90,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: optionLabel(option) })) + .map((option) => { + const icon = permissionGlyph(option.value) + return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } } + }) const submit = (id: string): void => { setPick(id) @@ -102,10 +143,14 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect disabled={locked || busy} onClick={() => { setOpen(!open) }} > + {permissionGlyph(currentValue) !== undefined && ( + <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span> + )} <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span> - <svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden> - <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /> - </svg> + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden> + <IconChevronDownOutline14 /> + </span> </button> } /> diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 56e7244d21..e67037db7d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,6 +1,7 @@ /* Todo strip in the composer context stack (Figma 1236:32276): tip surface, - 14px radius, status icons + secondary item labels. Its visible card aligns - with the GoalBar and the Queue panel inside their shared dock column. */ + status icons + secondary item labels. Its visible card aligns with the + GoalBar and the Queue panel inside their shared dock column: 12px radius, + 36px collapsed row, 12px side padding, 14px tertiary leading glyph. */ .root { box-sizing: border-box; @@ -24,7 +25,7 @@ var(--dsh-composer-dock-inset) ); border: 1px solid var(--dsw-alias-border-l1); - border-radius: 14px; + border-radius: 12px; background: var(--dsw-specific-tip); /* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu surface, and `.list` scrolls inside this card, so the thumb takes the l2 @@ -39,7 +40,7 @@ display: flex; flex-direction: column; gap: 8px; - padding: 9px 15px; + padding: 6px 12px; } .header { @@ -54,9 +55,16 @@ cursor: pointer; } +.lead { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + .title { flex: none; - font-size: 14px; + font-size: 13px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index c820902ef5..da6faa5794 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -13,7 +13,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots // declare) and the payload type. Type-only by construction — the outlet is // free of host value imports, so no host Context merge enters this program. import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' -import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChecklistOutline14, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { NS } from '../locales.ts' import css from './TodoPanel.module.css' @@ -78,11 +78,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) { } } -/** Header summary: "<done>/<total> tasks · <n> in progress". */ +/** Header summary: "·"-joined per-status counts; zero-count segments are omitted as noise (a non-empty list keeps at least one). */ function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string { const done = todos.filter(item => item.status === 'completed').length const active = todos.filter(item => item.status === 'in_progress').length - return t('todo.progress', { done, total: todos.length, active }) + const pending = todos.length - done - active + // En spaces (U+2002): HTML collapses runs of ASCII spaces, so widening the + // separator breathing room needs a literal wide space. + return [ + ...done > 0 ? [t('todo.progress.done', { done })] : [], + ...active > 0 ? [t('todo.progress.active', { active })] : [], + ...pending > 0 ? [t('todo.progress.pending', { pending })] : [], + ].join('\u2002·\u2002') } export function TodoPanel({ todos, t }: TodoPanelProps) { @@ -98,6 +105,7 @@ export function TodoPanel({ todos, t }: TodoPanelProps) { aria-expanded={!collapsed} onClick={() => { setCollapsed(v => !v) }} > + <span className={css.lead} aria-hidden><IconChecklistOutline14 /></span> <span className={css.title}>{t('todo.title')}</span> <span className={css.progress}>{progressLabel(todos, t)}</span> <span className={css.chevron} aria-hidden> @@ -129,19 +137,18 @@ export function TodoDock({ useProjection, t }: TodoDockProps) { } /** - * The plan strip as a plain registrant plugin (QueueDock posture). - * `inject: ['conversation']` is the ordering seam: the conversation service - * mounts after ui-conversation's slot registrations, so the - * 'conversation.input.dock' declaration is on the ledger by then. + * The plan strip as a plain registrant plugin (QueueDock posture), following + * the input-dock declaration across independent activation and reload. */ export const todoDockEntry = { name: 'conversation-todo-dock', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the plan strip before the goal and queue entries (order 0). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock) + ctx.slots.inject('conversation.input.dock', () => + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx index bda937266e..727e1e53ad 100644 --- a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx @@ -83,19 +83,19 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr } /** - * The ask-question row as a plain registrant plugin, riding the same - * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the - * chat entry (and with it the 'conversation.chat.toolview' declaration) is on - * the ledger. + * The ask-question row as a plain registrant plugin following the chat + * toolview declaration across independent activation and reload lifetimes. */ export const askQuestionToolview = { name: 'ask-question-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the ask-question row into the chat view's keyed toolview hole. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS }, AskQuestionRow) + ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ + name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS, + }, AskQuestionRow)) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index fbe05e0a1b..54e021639f 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -166,19 +166,18 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: } /** - * The sample as a plain registrant plugin. `inject` carries the load-order - * seam: requiring the conversation service guarantees the chat entry (and - * with it the 'conversation.chat.toolview' declaration) is registered — - * ui-conversation's apply mounts the service after the chat entry. + * The sample as a plain registrant plugin. Slot injection follows the chat + * toolview declaration across independent activation and reload lifetimes. */ export const bashToolviewSample = { name: 'bash-toolview-sample', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the bash row into the chat view's keyed toolview hole. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow) + ctx.slots.inject('conversation.chat.toolview', () => + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow)) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 227726cf61..fccb776c78 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -53,21 +53,21 @@ export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: } /** - * The file-mutation rows as a plain registrant plugin. `inject` carries the - * load-order seam: requiring the conversation service guarantees the chat entry - * (and with it the 'conversation.chat.toolview' declaration) is registered — - * ui-conversation's apply mounts the service after the chat entry. + * The file-mutation rows as a plain registrant plugin following the chat + * toolview declaration across independent activation and reload lifetimes. */ export const fileMutationToolview = { name: 'file-mutation-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the file-mutation row into the chat view's keyed toolview hole * under both mutation tool names. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow) + ctx.slots.inject('conversation.chat.toolview', function* () { + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow) + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow) + }) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx index 8d3694eeef..6d234ed82a 100644 --- a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx @@ -48,19 +48,18 @@ export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowP } /** - * The read row as a plain registrant plugin. `inject` carries the load-order - * seam: requiring the conversation service guarantees the chat entry (and with - * it the 'conversation.chat.toolview' declaration) is registered — - * ui-conversation's apply mounts the service after the chat entry. + * The read row as a plain registrant plugin following the chat toolview + * declaration across independent activation and reload lifetimes. */ export const readToolview = { name: 'read-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the read row into the chat view's keyed toolview hole. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow) + ctx.slots.inject('conversation.chat.toolview', () => + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 0e8a90ed7a..d2f9eba089 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -61,22 +61,22 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { } /** - * The search toolview as a plain registrant plugin. `inject` carries the - * load-order seam: requiring the conversation service guarantees the chat entry - * (and with it the 'conversation.chat.toolview' declaration) is registered. - * The one component registers under both keys, since `grep` and `glob` are the - * same visual object discriminated only by the result view's `kind`. + * The search toolview follows the chat toolview declaration across activation + * and reload. One component registers under both keys because `grep` and + * `glob` are the same visual object discriminated by the result view's `kind`. */ export const searchToolview = { name: 'search-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the search row into the chat view's keyed toolview hole under both * the `grep` and `glob` tool names. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow) + ctx.slots.inject('conversation.chat.toolview', function* () { + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow) + }) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index e6abaa171f..4a66d9aa4d 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -71,18 +71,18 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { } /** - * The todo row as a plain registrant plugin, riding the same load-order seam - * as the bash sample: `inject: ['conversation']` guarantees the chat entry - * (and with it the 'conversation.chat.toolview' declaration) is on the ledger. + * The todo row as a plain registrant plugin following the chat toolview + * declaration across independent activation and reload lifetimes. */ export const todoToolview = { name: 'todo-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the todo row into the chat view's keyed toolview hole. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow) + ctx.slots.inject('conversation.chat.toolview', () => + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx index 4d8ab5f59f..ea323b7ee6 100644 --- a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -55,20 +55,20 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { } /** - * The web rows as a plain registrant plugin, riding the same load-order seam as - * the bash sample: `inject: ['conversation']` guarantees the chat entry (and - * with it the 'conversation.chat.toolview' declaration) is on the ledger. One - * WebRow component registers under both web tool names. + * The web rows follow the chat toolview declaration across activation and + * reload. One WebRow component registers under both web tool names. */ export const webToolview = { name: 'web-toolview', - inject: ['slots', 'conversation'], + inject: ['slots'], /** * Register the web row under both web tool names' keyed toolview holes. * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow) + ctx.slots.inject('conversation.chat.toolview', function* () { + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow) + yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow) + }) }, } diff --git a/packages/client/ui-conversation/tests/ask-question-row.spec.tsx b/packages/client/ui-conversation/tests/ask-question-row.spec.tsx index e97376496c..c6b7c60ba5 100644 --- a/packages/client/ui-conversation/tests/ask-question-row.spec.tsx +++ b/packages/client/ui-conversation/tests/ask-question-row.spec.tsx @@ -124,11 +124,13 @@ describe('AskQuestionRow', () => { expect(screen.getByRole('button', { expanded: true })).toBeTruthy() }) - it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => { + it('askQuestionToolview injects the toolview declaration directly', () => { expect(askQuestionToolview.name).toBe('ask-question-toolview') - expect(askQuestionToolview.inject).toEqual(['slots', 'conversation']) - const register = vi.fn() - askQuestionToolview.apply({ slots: { register } } as never) + expect(askQuestionToolview.inject).toEqual(['slots']) + const register = vi.fn(() => () => undefined) + const inject = vi.fn((_name: string, callback: () => () => void) => callback()) + askQuestionToolview.apply({ slots: { inject, register } } as never) + expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function)) expect(register).toHaveBeenCalledWith( { name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' }, AskQuestionRow, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 7cae1606b9..3a1a968bea 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -122,7 +122,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => { // (default-collapsed: the header summary shows; rows appear on expand). const panel = view.container.querySelector('[data-testid="todo-panel"]') expect(panel).not.toBeNull() - expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中') + expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理') fireEvent.click(panel!.querySelector('button')!) expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status'))) .toEqual(['completed', 'in_progress', 'pending']) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 20e7e54f86..1415d4edb0 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -3,8 +3,8 @@ // as the first 'conversation.view' ring entry declaring the keyed toolview // hole, the slot registrations land against a root entry's children // declarations (the AppFrame role), the shared store handle rides all strict -// session entries, and the bash sample + todo row mount through the -// load-order seam as keyed entries. Full-chain rendering belongs to the +// session entries, and the bash sample + todo row mount through declaration +// injection as keyed entries. Full-chain rendering belongs to the // machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec // stops at the assembly surface. @@ -90,10 +90,9 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the tool rows as keyed entries through declaration injection', async () => { const b = await bench() - // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. The + // The actual toolview declaration activates every registrant. The // file-mutation registrant claims both write and edit for the diff card; the // one search row registers under both grep and glob; the web rows register // one component under both web tool names. diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 89a09ca28d..53793f86c2 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Remaining chat branch tails: MessageItem context/unknown/steering arms, +// Remaining chat branch tails: MessageItem context/unknown arms, // user IconActions, StatsLine no-cache join, // AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live // with the keyed-slot machinery specs since the tool ring dissolved into @@ -18,9 +18,18 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' +/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) afterEach(() => { cleanup() vi.useRealTimers() + vi.unstubAllGlobals() }) // Mirrors the real lookup chain (conversation namespace, then common). @@ -102,16 +111,10 @@ describe('MessageItem arms', () => { expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') }) - it('user copy stays quiet when execCommand throws or is absent', () => { + it('user copy never claims success when the host rejects the write', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, - value: undefined, - }) - Object.defineProperty(document, 'execCommand', { - configurable: true, - value: () => { - throw new Error('denied') - }, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, }) render( <MessageItem t={t} node={{ @@ -122,15 +125,94 @@ describe('MessageItem arms', () => { />, ) fireEvent.click(screen.getByRole('button', { name: '复制' })) - - Object.defineProperty(document, 'execCommand', { - configurable: true, - value: undefined, + await act(async () => { + await Promise.resolve() + await Promise.resolve() }) - fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() }) - it('consumed steering renders copy and branch actions without a badge', () => { + it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render( + <MessageItem t={t} node={{ + kind: 'user', seq: 1, time: 1_000, + content: [{ type: 'text', text: 'copied body' }] as never, + source: null, + }} + />, + ) + const copy = screen.getByRole('button', { name: '复制' }) + fireEvent.click(copy) + fireEvent.click(copy) + expect(writeText).toHaveBeenCalledTimes(1) + // Two microtask ticks: writeClipboard's own await, then the .then that + // lands the success chrome. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + const done = screen.getByRole('button', { name: '复制成功' }) + fireEvent.click(done) + expect(writeText).toHaveBeenCalledTimes(1) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('clears copy feedback work when the message unmounts', async () => { + vi.useFakeTimers() + let finishWrite!: () => void + const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve })) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const view = render( + <MessageItem t={t} node={{ + kind: 'user', seq: 1, time: 1_000, + content: [{ type: 'text', text: 'copied body' }] as never, + source: null, + }} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + view.unmount() + await act(async () => { + finishWrite() + await Promise.resolve() + await Promise.resolve() + }) + expect(vi.getTimerCount()).toBe(0) + + const mounted = render( + <MessageItem t={t} node={{ + kind: 'user', seq: 2, time: 1_000, + content: [{ type: 'text', text: 'copied body' }] as never, + source: null, + }} + />, + ) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + mounted.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -145,7 +227,7 @@ describe('MessageItem arms', () => { onFork={fork} />, ) - expect(view.queryByText('插话')).toBeNull() + expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) @@ -154,47 +236,480 @@ describe('MessageItem arms', () => { expect(fork).toHaveBeenCalledWith(2) }) - it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { + it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { const ctxView = render( <MessageItem t={t} node={{ kind: 'context', seq: 3, - content: [{ type: 'text', text: 'x\n"y":,[{}]' }], + content: [{ type: 'text', text: 'line one\n\nline two' }], source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] }, + provenance: { role: 'inject', label: 'fixture' }, + form: null, } as never} />, ) - const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( - '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' - + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', - ) + // An unknown form renders the opaque body: the model-facing text keeps its + // real line breaks instead of being escaped into one JSON line, and the + // remaining provenance follows it as fields. + expect(ctxView.container.querySelector('[data-context-text]')?.textContent) + .toBe('line one\n\nline two') + const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) + expect(fields).toEqual(['plugin', 'empty', 'list']) fireEvent.keyDown(disclosure, { key: ' ' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') }) - it('context preserves the bounded JSON truncation contract', () => { + it('the instructions form names the files it reconciled above their text', () => { const view = render( <MessageItem t={t} node={{ kind: 'context', seq: 3, - content: [{ type: 'text', text: 'x'.repeat(21_000) }], + content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + baseline: true, + changes: [ + { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, + { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, + { action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' }, + ], + }, + provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, + form: 'instructions', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除']) + // The `<system-reminder>` framing is part of what the model read, so the + // body keeps it verbatim rather than presenting a cleaned-up excerpt. + expect(view.container.querySelector('[data-context-text]')?.textContent) + .toContain('<system-reminder>') + }) + + it('a delta distinguishes a newly reconciled file from a rewritten one', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'delta' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + changes: [ + { action: 'set', scope: 'a', path: 'new/AGENTS.md' }, + { action: 'replace', scope: 'b', path: 'old/AGENTS.md' }, + ], + }, + provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' }, + form: 'instructions', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新']) + }) + + it('keeps an interleaved unknown block in the order the model received it', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [ + { type: 'text', text: 'before' }, + { type: 'future-block', payload: 1 }, + { type: 'text', text: 'after' }, + ], source: null, + provenance: { role: 'inject', label: null }, + form: null, } as never} />, ) fireEvent.click(view.getByRole('button', { name: '上下文注入' })) - expect(view.container.querySelector('[data-context-injection-body]')?.textContent) + const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent) + expect(texts).toEqual(['before', 'after']) + expect(view.getByText(/未知内容块/)).toBeTruthy() + }) + + it('the catalog form lists its durable entries instead of the model-facing prose', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }], + }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) + expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) + expect(view.container.querySelector('[data-context-text]')).toBeNull() + expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull() + }) + + it('a replacement catalog says so above its entries', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'catalog prose' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries: [{ name: 'a-skill', description: 'Does A' }], + }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') + }) + + it('a partially unreadable catalog falls back whole rather than showing a short list', () => { + // All-or-nothing: a body that replaces the model-facing text must not show + // a confident, incomplete account of what the model read. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'catalog prose' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }], + }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-entries]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') + // The marker reports what rendered, not what was declared. + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBeNull() + }) + + it('an unreadable instruction list falls back to the opaque body with its fields', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'instruction prose' }], + source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] }, + provenance: { role: 'inject', label: 'workspace-instructions' }, + form: 'instructions', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) + expect(view.container.querySelector('[data-context-files]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') + expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() + }) + + it('joins adjacent text blocks the way a provider adapter flattens them', () => { + // No invented separator: showing a line break the model never saw would + // misreport the request. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }], + source: null, + provenance: { role: 'inject', label: null }, + form: null, + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond') + }) + + it('bounds an oversized provenance field, not only the model-facing text', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'short' }], + source: { kind: 'plugin', note: 'y'.repeat(21_000) }, + provenance: { role: 'inject', label: 'plugin' }, + form: null, + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) + expect(view.container.querySelector('[data-context-fields] dd')?.textContent) .toMatch(/… 已截断,共 \d+ 字符$/) }) + it('an empty replacement catalog stays a catalog: it retires every earlier name', () => { + // `renderCatalogUpdate` legitimately publishes zero entries when the last + // skill disappears; falling back would hide that the catalog was cleared. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'catalog prose' }], + source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') + expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0) + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBe('catalog') + }) + + it('a catalog whose entries are unreadable falls back to the opaque body', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'catalog prose' }], + source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-entries]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') + }) + + it('bounds a large catalog and says how many rows it withheld', () => { + const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' })) + const view = render( + <MessageItem t={t} node={{ + kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }], + source: { kind: 'skill-catalog', form: 'catalog', entries }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200) + expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条') + }) + + it('a catalog keeps a content block this version does not know', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }], + source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.getByText(/未知内容块/)).toBeTruthy() + }) + + it('an instruction change with an unrecognized action falls back whole', () => { + // The action decides the word the row shows, so an unknown one cannot be + // presented as loaded or updated. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'instruction prose' }], + source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] }, + provenance: { role: 'inject', label: 'workspace-instructions' }, + form: 'instructions', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) + expect(view.container.querySelector('[data-context-files]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') + }) + + it('the opaque fallback keeps a form declaration this version cannot present', () => { + // Otherwise a newer or foreign log's declared shape vanishes from the UI. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }], + source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' }, + provenance: { role: 'inject', label: 'later' }, + form: null, + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ })) + const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) + expect(fields).toEqual(['plugin', 'form']) + }) + + it('the snapshot form attributes each part to the subsystem that produced it', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }], + source: { + kind: 'plugin', + plugin: '@deepseek-ai/dsh-system-prompt', + form: 'snapshot', + sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }], + }, + provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' }, + form: 'snapshot', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ })) + const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent) + expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo']) + }) + + it('a notice puts its account on the collapsed row', () => { + // The whole point of the form: readable without expanding. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'background task bash-1 finished.' }], + source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' }, + provenance: { role: 'inject', label: 'tool-tasks' }, + form: 'notice', + } as never} + />, + ) + expect(view.container.querySelector('[data-context-summary]')?.textContent) + .toBe('bash pnpm test [status: completed]') + expect(view.container.querySelector('[data-context-injection-body]')).toBeNull() + }) + + it('a notice without its account falls back to the opaque body', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }], + source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' }, + provenance: { role: 'inject', label: 'tool-tasks' }, + form: 'notice', + } as never} + />, + ) + expect(view.container.querySelector('[data-context-summary]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ })) + expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() + }) + + it('each form falls back to the opaque body when its required facts are unreadable', () => { + // The fallback chain is the load-bearing wall: every dedicated form must + // reach it, and the row marker must not claim a form that did not render. + const cases = [ + { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' }, + { form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' }, + { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' }, + ] as const + for (const { form, source, label } of cases) { + cleanup() + const view = render( + <MessageItem t={t} node={{ + kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }], + source, provenance: { role: 'inject', label }, form, + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) })) + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`) + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBeNull() + } + }) + + it('a snapshot states the supersession its framing line carries', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }], + source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] }, + provenance: { role: 'inject', label: 'plugin' }, + form: 'snapshot', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) + expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent) + .toBe('取代先前的快照') + }) + + it('a relay names the agent that sent it above what it said', () => { + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'child report body' }], + source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' }, + provenance: { role: 'inject', label: 'subagent-report' }, + form: 'relay', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ })) + expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7') + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body') + }) + + it('a recall reports how much of each source session survived the read', () => { + // Recalled context is bounded on the way in, so hiding the omitted count + // would overstate what the model received. + const view = render( + <MessageItem t={t} node={{ + kind: 'context', + seq: 3, + content: [{ type: 'text', text: 'recalled material' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [ + { label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true }, + { label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false }, + ], + }, + provenance: { role: 'recall', label: '重构 loader, 修 CI' }, + form: 'recall', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ })) + const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent) + expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条']) + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material') + }) + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( <MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />, @@ -496,12 +1011,13 @@ describe('small branch tails', () => { const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( <StatsLine + t={t} useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} useProjection={(key: string) => key === 'tokenUsage' ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } : undefined} />, ) - expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok') + expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok') }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index f07104ba7b..8c4af6a921 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore<ConversationSnapshot>(snapshot) const list = createSnapshotStore<SessionListState>({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 8368c7cca1..7187851420 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -3,25 +3,40 @@ // hard acceptance — zero renders during streaming. Bash sample row: ToolRow // chrome (Bash · description) without a row click target. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' +import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' type BashRowProps = Parameters<typeof BashRow>[0] // Mirrors the real lookup chain (conversation namespace, then common). const t: BashRowProps['t'] = makeTranslate(zh, commonZh) +const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn) -afterEach(cleanup) +/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() +}) const SID = 's1' as SessionId @@ -66,8 +81,11 @@ describe('deriveStats', () => { expect(stats.turns).toBe(2) expect(stats.steps).toBe(3) // Window-scoped by design: the paged window is not an accounting source, so - // the fold exposes no token fields at all (billing rides the projection). - expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns']) + // the fold exposes no billing fields (billing rides the projection); + // decodeTokens is a throughput input, not a billed total. + expect(Object.keys(stats).sort()).toEqual( + ['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'], + ) }) it('ignores tool results with no call time', () => { @@ -97,6 +115,23 @@ describe('deriveStats', () => { expect(stats.llmMs).toBe(2_500) expect(stats.toolMs).toBe(3_000) }) + + it('sums ttft per recorded step and decode throughput inputs per usage-carrying step', () => { + const sampled: AssistantMessageNode = { + ...assistant(1, 1, { outputTokens: 40 }), + timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 }, + } + const ttftOnly: AssistantMessageNode = { + ...assistant(2, 1), + timing: { stepStartTime: 5_000, firstTokenTime: 5_400, completedTime: 7_400 }, + } + const stats = deriveStats([sampled, ttftOnly, assistant(3, 2)]) + expect(stats.ttftMs).toBe(1_200) + expect(stats.ttftSteps).toBe(2) + // The usage-less step contributes no decode share, keeping the ratio honest. + expect(stats.decodeMs).toBe(3_000) + expect(stats.decodeTokens).toBe(40) + }) }) describe('formatters', () => { @@ -125,7 +160,7 @@ describe('StatsLine', () => { source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }, values: Record<string, unknown> = { tokenUsage: USAGE }, ): StatsLineProps { - return { useSession: bindSnapshotSelector(source), useProjection: projections(values) } + return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } } it('renders the grouped stats row and hides a brand-new empty session', () => { @@ -133,7 +168,7 @@ describe('StatsLine', () => { const view = render(<StatsLine {...props(source)} />) // No timing on the fixture: the duration group drops out whole. Tokens come // from the projection, so paging the window cannot change them. - expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok') + expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok') const empty = makeSource() const emptyView = render(<StatsLine {...props(empty.source, { tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, @@ -142,47 +177,84 @@ describe('StatsLine', () => { expect(emptyView.container.textContent).toBe('') }) - it('keeps durable token and context groups after the visible step window is empty', () => { + it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => { + vi.useFakeTimers() + // jsdom lays nothing out; fake a row narrower than its content. + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400) + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render(<StatsLine {...props(source)} />) + fireEvent.mouseEnter(view.container.firstElementChild!) + act(() => { vi.advanceTimersByTime(499) }) + expect(view.container.querySelector('[role="tooltip"]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(view.container.querySelector('[role="tooltip"]')?.textContent) + .toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok') + }) + + it('suppresses the tooltip while the row fits without truncation', () => { + vi.useFakeTimers() + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render(<StatsLine {...props(source)} />) + fireEvent.mouseEnter(view.container.firstElementChild!) + act(() => { vi.advanceTimersByTime(500) }) + expect(view.container.querySelector('[role="tooltip"]')).toBeNull() + }) + + it('renders window latency and throughput beside the wall-time group', () => { + const timed: AssistantMessageNode = { + ...assistant(1, 1, { outputTokens: 60 }), + timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 }, + } + const { source } = makeSource({ nodes: [timed] }) + const view = render(<StatsLine {...props(source)} />) + expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s') + }) + + it('takes every stats label from the active locale', () => { + const timed: AssistantMessageNode = { + ...assistant(1, 1, { outputTokens: 60 }), + timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 }, + } + const { source } = makeSource({ nodes: [timed] }) + const view = render(<StatsLine {...props(source)} t={t} />) + expect(view.container.textContent) + .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok') + }) + + it('renders without ResizeObserver support', () => { + vi.unstubAllGlobals() + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + expect(() => render(<StatsLine {...props(source)} />)).not.toThrow() + }) + + it('keeps durable token groups after the visible step window is empty', () => { const { source } = makeSource() const view = render(<StatsLine {...props(source, { tokenUsage: USAGE, contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, })} />) + // Context occupancy lives on the composer's ContextMeter ring, not here. expect(view.container.textContent) - .toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok') + .toBe('Cache hit 90%| Input 100 tok · Output 5 tok') }) - it('renders context occupancy only when the projection knows a capacity', () => { - const { source } = makeSource({ nodes: [assistant(1, 1)] }) - const withCapacity = render(<StatsLine {...props(source, { - tokenUsage: USAGE, - contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, - })} />) - expect(withCapacity.container.textContent).toContain('Context 25% of 128K') - // Pressure without capacity has no denominator: the group drops out. - const noCapacity = render(<StatsLine {...props(source, { - tokenUsage: USAGE, - contextPressure: { pressureTokens: 32_000 }, - })} />) - expect(noCapacity.container.textContent).not.toContain('Context') - // Capacity arrives before usage in the log; no provider sample means there - // is no numerator yet, rather than a synthetic 0%. - const noPressure = render(<StatsLine {...props(source, { - tokenUsage: USAGE, - contextPressure: { contextWindow: 128_000 }, - })} />) - expect(noPressure.container.textContent).not.toContain('Context') - }) - - it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => { - // Capacity and pressure are independent last-wins fields, so a model switch - // can pair a smaller new window with the previous route's larger prompt. - const { source } = makeSource({ nodes: [assistant(1, 1)] }) - const view = render(<StatsLine {...props(source, { - tokenUsage: USAGE, - contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 }, - })} />) - expect(view.container.textContent).toContain('Context 100% of 128K') + it('computes context occupancy only when both a numerator and capacity are known', () => { + // The projected figure wins: it is the provider sample carried forward over + // the surface's movement, so a compaction shows without waiting a request. + expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 })) + .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 }) + // A log whose projection predates the field still reads its bare sample. + expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 })) + .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 }) + // A numerator without capacity has no denominator; capacity without a + // provider sample has no numerator yet, rather than a synthetic 0%. + expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull() + expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull() + expect(contextOccupancy(undefined)).toBeNull() + // Capacity and the sample are independent last-wins fields, so a model + // switch can pair a smaller new window with the previous route's prompt. + expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100) }) it('drops every token group when no projection is composed', () => { @@ -196,7 +268,7 @@ describe('StatsLine', () => { const view = render(<StatsLine {...props(source, { tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 }, })} />) - expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok') + expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok') }) it('includes cache writes in billed input and the cache-hit denominator', () => { @@ -210,7 +282,7 @@ describe('StatsLine', () => { }, })} />) expect(view.container.textContent) - .toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok') + .toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok') }) it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => { @@ -244,7 +316,7 @@ describe('bash sample row', () => { return createSnapshotStore<SessionListState>({ ids: [SID], byId: { - [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, + [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index b312d1bd9a..810a810fbb 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -59,6 +59,7 @@ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({ describe('tool-call-model', () => { it('classifies known tools and falls back to others', () => { expect(classifyTool('bash')).toBe('bash') + expect(classifyTool('pwsh')).toBe('bash') expect(classifyTool('read')).toBe('read') expect(classifyTool('web_fetch')).toBe('read') expect(classifyTool('web_search')).toBe('search') @@ -71,6 +72,12 @@ describe('tool-call-model', () => { expect(classifyTool('todo_write')).toBe('others') }) + it('gives the pwsh shell row the bash family treatment with its own title', () => { + const m = toolRowModel('pwsh', running()) + expect(m.variant).toBe('bash') + expect(m.title).toBe('Pwsh') + }) + it('derives state across running/ok/error/interrupted', () => { expect(toolRowModel('bash', running()).state).toBe('running') expect(toolRowModel('bash', result()).state).toBe('ok') diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 34f2ff9cfd..e2319157ea 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -6,9 +6,8 @@ // entryKey (the bash sample lands through its plugin), unregistered tools // fall back to GenericToolCard at the render site, live registration/unload // flips rows in place, duplicate keys fail loud, the inject channel feeds -// (sessionId) => I into row components, and a registrant's -// inject: ['slots', 'conversation'] load-order seam suspends on real fiber -// semantics until the service (and with it the hole declaration) is present. +// (sessionId) => I into row components, and a registrant can activate before +// the declaration then land through slots.inject when the chat entry appears. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent } from '@testing-library/react' @@ -191,8 +190,8 @@ describe('keyed toolview hole through the real machinery', () => { }) }) -describe('registrant load-order seam', () => { - it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => { +describe('registrant declaration injection', () => { + it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) @@ -200,31 +199,26 @@ describe('registrant load-order seam', () => { runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) - // Third-party posture, mounted BEFORE ui-conversation: real fiber inject - // semantics hold it — apply must not run while 'conversation' is absent. - // Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount() - // would fail loud on the missing service). (Plain arrow, not vi.fn: mock - // functions carry a prototype and trip the fiber's isConstructor branch.) + // Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs, + // while slots.inject waits for the declaration itself. let applyRuns = 0 const registrantApply = (registrantCtx: typeof runtime.ctx): void => { applyRuns += 1 - registrantCtx.slots.register( - { name: 'conversation.chat.toolview', key: 'late' }, () => null) + registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register( + { name: 'conversation.chat.toolview', key: 'late' }, () => null)) } const late = runtime.ctx.plugin({ name: 'late-registrant', - inject: ['slots', 'conversation'], + inject: ['slots'], apply: registrantApply, }) await Promise.resolve() - expect(applyRuns).toBe(0) - - // Mounting the package resolves the seam: service present ⟹ the chat - // entry (and its hole declaration) is already on the ledger, so the - // suspended registrant lands without an undeclared-slot throw. - await runtime.mount({ inject: [...inject], apply }) await late.await() expect(applyRuns).toBe(1) + expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0) + + // Mounting the package declares the slot and activates the waiting entry. + await runtime.mount({ inject: [...inject], apply }) expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) await runtime.dispose() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3a0ca4f7b0..b7ca8dd149 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -274,11 +274,7 @@ describe('chat-flow derivation', () => { user(6, 'second'), assistant(7, 'clean tail', 2), user(10, 'user-only tail'), - { - kind: 'steering', messageId: 'steering-tail' as never, - seq: 13, time: 13_000, turn: 4, - content: [{ type: 'text', text: 'steering tail' }], source: null, - }, + user(13, 'steering tail'), ] const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) expect([...seqs]).toEqual([7, 10, 13]) @@ -380,6 +376,9 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() + // Pending and durable steering carry the same interjection caption, so the + // hand-off does not change what the row says it is. + expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -393,7 +392,7 @@ describe('ChatView', () => { assistant(1, 'working'), { kind: 'steering', messageId: pending.messageId, - seq: 2, time: 2_000, turn: 1, + seq: 2, time: 2_000, content: [{ type: 'text', text: 'interrupt now' }], source: null, }, ], @@ -401,6 +400,7 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + expect(view.getAllByText('插话')).toHaveLength(1) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) @@ -430,8 +430,7 @@ describe('ChatView', () => { const h = makeHarness({ queue: [pending], nodes: [{ - kind: 'steering', messageId: pending.messageId, - seq: 2, time: 2_000, turn: 1, + kind: 'user', seq: 2, time: 2_000, content: pending.content, source: null, }], running: true, @@ -447,6 +446,8 @@ describe('ChatView', () => { const nextRetry = { ...retry(3), turn: 2, retry: 2 } const context = { kind: 'context', seq: 4, time: 4_000, content: [], source: null, + provenance: { role: 'inject', label: null }, + form: null, } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render(<h.ChatView {...h.props} />) @@ -540,6 +541,45 @@ describe('ChatView', () => { expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) }) + it('the settled footer appends first-step ttft and turn decode throughput', () => { + const first: AssistantMessageNode = { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }], + timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, + usage: { outputTokens: 40 }, + } + const second: AssistantMessageNode = { + kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }], + timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 }, + usage: { outputTokens: 60 }, + } + const h = makeHarness({ + nodes: [user(1, 'hi'), first, second], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), + turnEnds: new Map([[1, 20]]), + }) + const view = render(<h.ChatView {...h.props} />) + // First-step ttft (1.2s) plus 100 tokens over 5s of decode. + expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) + expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1) + expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1) + }) + + it('withholds ttft and throughput while the turn is still running', () => { + const settled: AssistantMessageNode = { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }], + timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 }, + usage: { outputTokens: 10 }, + } + const h = makeHarness({ + nodes: [user(1, 'hi'), settled], + turnTimings: new Map([[1, { startTime: 1_000 }]]), + turnEnds: new Map(), + running: true, + }) + const view = render(<h.ChatView {...h.props} />) + expect(view.queryByText(/首 token|tok\/s/)).toBeNull() + }) + it('user and assistant message containers scope the hover-revealed time chrome', () => { const h = makeHarness({ nodes: [user(1, 'hi'), assistant(2, 'answer')], @@ -732,9 +772,13 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { - h.set({ nodes: [trigger, { - kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1, - content: [{ type: 'text', text: 'also' }], source: null, + h.set({ queue: [{ + id: 'steering-occurrence' as never, + messageId: 'steering-message' as never, + placement: 'steering', + content: [{ type: 'text', text: 'also' }], + preview: 'also', + text: 'also', }] }) }) expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) diff --git a/packages/client/ui-conversation/tests/context-meter.spec.tsx b/packages/client/ui-conversation/tests/context-meter.spec.tsx new file mode 100644 index 0000000000..f4bd33bb8e --- /dev/null +++ b/packages/client/ui-conversation/tests/context-meter.spec.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +// ContextMeter (composer trailing control): occupancy ring gating, the +// click-open breakdown panel, and its close gestures. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts' +import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx' +import css from '../src/client/skeleton/ContextMeter.module.css' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +// Mirrors the real lookup chain (conversation namespace, then common). +const t = makeTranslate(zh, commonZh) as ContextMeterProps['t'] +const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t'] + +const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 } + +const segmentClass = css.segment +if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css') + +/** Stub the projection seat: a key-addressed table of whole values. */ +function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] { + return (key: string) => values[key] +} + +function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) { + return render(<ContextMeter useProjection={projections(values)} t={translate} />) +} + +describe('ContextMeter', () => { + it('renders nothing until both pressure and capacity are known', () => { + expect(meter({}).container.textContent).toBe('') + expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('') + expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('') + }) + + it('shows the occupancy ring and opens the breakdown panel on click', () => { + const view = meter({ + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + }) + const trigger = view.getByRole('button', { name: '上下文已用 25%' }) + expect(view.container.querySelector('[role="dialog"]')).toBeNull() + fireEvent.click(trigger) + const panel = view.container.querySelector('[role="dialog"]')! + expect(panel.textContent).toContain('~32K / 128K') + expect(panel.textContent).toContain('25%') + expect(panel.textContent).toContain('上下文已用') + expect(panel.textContent).toContain('系统提示词~120') + expect(panel.textContent).toContain('工具~21.5K') + expect(panel.textContent).toContain('对话消息~477K') + // The occupancy bar splits into one colored segment per composition row. + expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3) + // Clicking the trigger again toggles the panel shut. + fireEvent.click(trigger) + expect(view.container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('lets each locale own the headline word order around the reading', () => { + const values = { + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + } + const zhView = meter(values) + fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' })) + // The reading follows the label in Chinese and leads it in English; both + // headers read as one sentence rather than a concatenated fragment. + expect(zhView.container.querySelector('[role="dialog"]')!.textContent) + .toMatch(/^上下文已用25%/) + const enView = meter(values, tEn) + fireEvent.click(enView.getByRole('button', { name: '25% of context used' })) + expect(enView.container.querySelector('[role="dialog"]')!.textContent) + .toMatch(/^25%of context used/) + }) + + it('draws no bar segment at zero occupancy', () => { + const view = meter({ + contextPressure: { pressureTokens: 0, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + }) + fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' })) + const panel = view.container.querySelector('[role="dialog"]')! + // `.segment` carries a min-width, so a zero-width part would still paint a + // filled sliver over an empty context. + expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0) + expect(panel.textContent).toContain('~0 / 128K') + }) + + it('reads the ring from the projected figure so a compaction shows at once', () => { + // Same provider sample, a surface a compaction just shrank: the ring must + // follow the projection rather than the sample it is anchored to. + const view = meter({ + contextPressure: { pressureTokens: 32_000, projectedTokens: 3_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + }) + const trigger = view.getByRole('button', { name: '上下文已用 2%' }) + fireEvent.click(trigger) + expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K') + }) + + it('omits the composition rows while the contextBreakdown projection is absent', () => { + const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } }) + fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' })) + const panel = view.container.querySelector('[role="dialog"]')! + expect(panel.textContent).toContain('~32K / 128K') + expect(panel.textContent).not.toContain('系统提示词') + expect(panel.textContent).not.toContain('对话消息') + // Without composition shares, the bar falls back to one plain segment. + expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1) + }) + + it('closes when capacity disappears and stays closed when it returns', () => { + let values: Record<string, unknown> = { + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + } + const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />) + fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' })) + expect(view.container.querySelector('[role="dialog"]')).not.toBeNull() + + values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN } + view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />) + expect(view.container.textContent).toBe('') + + values = { + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + } + view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />) + expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('closes on outside pointerdown and Escape — but not inside clicks', () => { + const view = meter({ + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + }) + const trigger = view.getByRole('button', { name: '上下文已用 25%' }) + const openPanel = () => { + fireEvent.click(trigger) + return view.container.querySelector('[role="dialog"]')! + } + // A pointerdown inside the panel keeps it open; outside closes it. + const again = openPanel() + fireEvent.pointerDown(again) + expect(view.container.querySelector('[role="dialog"]')).not.toBeNull() + fireEvent.pointerDown(document.body) + expect(view.container.querySelector('[role="dialog"]')).toBeNull() + // Escape. + openPanel() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.container.querySelector('[role="dialog"]')).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 445b875109..c92e43db6c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -93,7 +93,7 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore<SessionListState>({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 0d0fd46407..46d5e80524 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -153,7 +153,7 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore<SessionListState>({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -274,8 +274,14 @@ describe('fileMutationToolview registration', () => { it('registers one component under both edit and write, and each disposes', () => { const registered: { key: string; locale: unknown; disposed: boolean }[] = [] const disposers: (() => void)[] = [] + let disposeInjection = (): void => {} const ctx = { slots: { + inject: (_name: string, callback: () => Iterable<() => void>) => { + const active = [...callback()] + disposeInjection = () => { for (const dispose of active.reverse()) dispose() } + return disposeInjection + }, register: ({ key, locale }: { name: string; key: string; locale?: string }) => { const entry = { key, locale, disposed: false } registered.push(entry) @@ -289,10 +295,9 @@ describe('fileMutationToolview registration', () => { expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) // Both keys claim the conversation locale seat ToolRow's body copy needs. expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation']) - // The registrant's inject seam is the load-order contract the row relies on. - expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) + expect(fileMutationToolview.inject).toEqual(['slots']) // Disposal removes each contribution (packages/AGENTS.md registry contract). - for (const dispose of disposers) dispose() + disposeInjection() expect(registered.every(r => r.disposed)).toBe(true) }) }) @@ -306,7 +311,7 @@ describe('DetailsPanel diff Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 85854c6e97..5adb4d817e 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,7 +18,18 @@ import { zh } from '../src/client/locales.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) -afterEach(cleanup) +/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) const SID = 's1' as SessionId @@ -56,11 +67,12 @@ describe('render branch tails', () => { const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( <StatsLine + t={t} useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} useProjection={() => undefined} />, ) - expect(view.container.textContent).toBe('2 turns · 3 steps') + expect(view.container.textContent).toBe('2 轮 · 3 步') }) it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index df86fdefdb..bc276174a3 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -143,7 +143,6 @@ function bench(over?: BenchOptions) { } const view = render(<InputBar {...props} />) const textarea = view.container.querySelector('textarea')! - // aria-label (not role name): title carries the same label and would double-match. const stopping = over?.running === true && over.subagent === undefined const button = view.container.querySelector<HTMLButtonElement>( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, @@ -724,6 +723,8 @@ describe('command launcher chrome and control seats', () => { const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement // Title-case display is presentation only; the menu ids stay machine names. expect(trigger.textContent).toBe('Read Only') + expect([...trigger.querySelectorAll('svg')] + .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true) fireEvent.click(trigger) const items = view.getAllByRole('menuitem') expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 9a37922668..8c04651e77 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -375,8 +375,10 @@ describe('QueueDock', () => { it('registers as the terminal composer-context entry', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) - const register = vi.fn() - queueDockEntry.apply({ slots: { register } } as never) + const register = vi.fn(() => () => undefined) + const inject = vi.fn((_name: string, callback: () => () => void) => callback()) + queueDockEntry.apply({ slots: { inject, register } } as never) + expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function)) expect(register).toHaveBeenCalledWith( expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }), QueueDock, diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index f8ecb8b73c..786b437134 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore<SessionListState>({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -237,11 +237,14 @@ describe('ReadRow keyed toolview', () => { it('registers under the read key of the keyed toolview slot', () => { const registered: { name: unknown; key?: unknown }[] = [] - const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context + const ctx = { slots: { + inject: (_name: string, callback: () => () => void) => callback(), + register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined }, + } } as unknown as Context readToolview.apply(ctx) // The row composes ToolRow, so it declares its locale namespace at the seat. expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }]) - expect(readToolview.inject).toContain('conversation') + expect(readToolview.inject).toEqual(['slots']) }) }) @@ -254,7 +257,7 @@ describe('DetailsPanel Output section (read)', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index cd572cc350..dcc6ca37ee 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -349,8 +349,13 @@ describe('SearchRow keyed card', () => { const registered: { key: unknown; locale: unknown; component: unknown }[] = [] const ctx = { slots: { + inject: (_name: string, callback: () => Iterable<() => void>) => { + for (const _dispose of callback()) { /* exhaust transactional setup */ } + return () => undefined + }, register: (options: { name: string; key: string; locale?: string }, component: unknown) => { registered.push({ key: options.key, locale: options.locale, component }) + return () => undefined }, }, } as never @@ -361,7 +366,7 @@ describe('SearchRow keyed card', () => { // One component, two keys. expect(registered[0]!.component).toBe(SearchRow) expect(registered[1]!.component).toBe(SearchRow) - expect(searchToolview.inject).toEqual(['slots', 'conversation']) + expect(searchToolview.inject).toEqual(['slots']) }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9f793a8c9b..b2828bcc80 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -94,10 +94,10 @@ function mount( } = {}, ) { const root = sid('root') - const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 } + const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 } const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', - running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2, + running: false, blank: options.summaryBlank ?? false, updatedAt: 2, ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index d4be802a34..f655979518 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -342,7 +342,7 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore<SessionListState>({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, @@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index fdaad9ab4a..9cd2075cd7 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -38,15 +38,24 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('starts collapsed with the progress summary visible', () => { + it('starts collapsed with the per-status count summary visible', () => { render(<TodoPanel todos={LIST} t={t} />) expect(screen.getByTestId('todo-panel')).toBeTruthy() - expect(screen.getByText('任务清单')).toBeTruthy() - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('任务')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByRole('list')).toBeNull() }) + it('omits the completed segment while nothing is done yet', () => { + render(<TodoPanel todos={[ + { content: '写组件', status: 'in_progress' }, + { content: '补测试', status: 'pending' }, + ]} t={t} />) + expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy() + expect(screen.queryByText(/已完成/)).toBeNull() + }) + it('expands to show one row per item with its status glyph', () => { render(<TodoPanel todos={LIST} t={t} />) fireEvent.click(screen.getByRole('button', { expanded: false })) @@ -65,17 +74,18 @@ describe('TodoPanel', () => { fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() // Collapsed header is title + progress only (no in-progress content hint). - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() expect(screen.queryByText('写组件')).toBeNull() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) - it('collapsed header still shows zero in-progress when nothing is active', () => { + it('an all-completed list collapses the summary to the done count alone', () => { render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />) expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByText('都完了')).toBeNull() - expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成')).toBeTruthy() + expect(screen.queryByText(/进行中|待处理/)).toBeNull() }) }) @@ -93,7 +103,7 @@ describe('TodoDock', () => { // Capability absent (no baseline/frame yet) renders nothing. expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ value: LIST }) }) - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() // The pre-first-write whole value (null) retires the strip (the panel owns no data). act(() => { store.set({ value: null }) }) expect(screen.queryByTestId('todo-panel')).toBeNull() @@ -101,9 +111,11 @@ describe('TodoDock', () => { it('registers before the goal and queue entries', () => { expect(todoDockEntry.name).toBe('conversation-todo-dock') - expect(todoDockEntry.inject).toEqual(['slots', 'conversation']) - const register = vi.fn() - todoDockEntry.apply({ slots: { register } } as never) + expect(todoDockEntry.inject).toEqual(['slots']) + const register = vi.fn(() => () => undefined) + const inject = vi.fn((_name: string, callback: () => () => void) => callback()) + todoDockEntry.apply({ slots: { inject, register } } as never) + expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function)) expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock) }) }) @@ -186,11 +198,13 @@ describe('TodoRow', () => { expect(screen.getByText('todo_write · c1')).toBeTruthy() }) - it('todoToolview is a plain registrant riding the conversation load-order seam', () => { + it('todoToolview injects the toolview declaration directly', () => { expect(todoToolview.name).toBe('todo-toolview') - expect(todoToolview.inject).toEqual(['slots', 'conversation']) - const register = vi.fn() - todoToolview.apply({ slots: { register } } as never) + expect(todoToolview.inject).toEqual(['slots']) + const register = vi.fn(() => () => undefined) + const inject = vi.fn((_name: string, callback: () => () => void) => callback()) + todoToolview.apply({ slots: { inject, register } } as never) + expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function)) expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow) }) }) diff --git a/packages/client/ui-conversation/tests/turn-metrics.spec.ts b/packages/client/ui-conversation/tests/turn-metrics.spec.ts new file mode 100644 index 0000000000..0e8d0546ac --- /dev/null +++ b/packages/client/ui-conversation/tests/turn-metrics.spec.ts @@ -0,0 +1,154 @@ +// Per-turn latency/throughput fold and the footer figure formatters. + +import { describe, expect, it } from 'vitest' +import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client' +import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts' +import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts' + +interface StepSpec { + seq: number + turn: number + step: number + timing?: AssistantMessageNode['timing'] + usage?: unknown +} + +const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text: `t${seq}` }], + ...(timing === undefined ? {} : { timing }), + ...(usage === undefined ? {} : { usage }), +}) + +const user = (seq: number): UserMessageNode => ({ + kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null, +}) + +describe('assistantStepReading', () => { + it('derives ttft, decode time, and output tokens from a fully recorded step', () => { + const reading = assistantStepReading(assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 6_800 }, + usage: { outputTokens: 200 }, + })) + expect(reading).toEqual({ ttftMs: 800, decodeMs: 5_000, outputTokens: 200 }) + }) + + it('returns nulls when timing is absent', () => { + const reading = assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, usage: { outputTokens: 5 } })) + expect(reading).toEqual({ ttftMs: null, decodeMs: null, outputTokens: 5 }) + }) + + it('needs both boundaries for ttft and clamps negative spans to zero', () => { + expect(assistantStepReading(assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: null, firstTokenTime: 1_800, completedTime: 6_800 }, + }))).toEqual({ ttftMs: null, decodeMs: 5_000, outputTokens: null }) + expect(assistantStepReading(assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 1_000, firstTokenTime: null, completedTime: 6_800 }, + }))).toEqual({ ttftMs: null, decodeMs: null, outputTokens: null }) + expect(assistantStepReading(assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 2_000, firstTokenTime: 1_500, completedTime: 1_200 }, + }))).toEqual({ ttftMs: 0, decodeMs: 0, outputTokens: null }) + }) + + it('rejects non-object, missing, and non-finite usage token counts', () => { + const timing = { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 } + expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: 'weird' })).outputTokens).toBeNull() + expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: {} })).outputTokens).toBeNull() + const nan = assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: Number.NaN } }) + expect(assistantStepReading(nan).outputTokens).toBeNull() + expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: -3 } })).outputTokens).toBeNull() + }) +}) + +describe('deriveTurnMetrics', () => { + it('takes ttft from the lowest step and throughput over all sampled steps', () => { + const nodes: ConversationNode[] = [ + user(1), + // Out of step order on purpose: the lowest step owns the ttft slot. + assistant({ + seq: 4, turn: 1, step: 2, + timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 }, + usage: { outputTokens: 60 }, + }), + assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, + usage: { outputTokens: 40 }, + }), + ] + // 100 tokens over 5s of decode. + expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 }) + }) + + it('emits ttft without throughput when no step carries usage', () => { + const nodes = [assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 }, + })] + expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 }) + }) + + it('emits throughput without ttft when only a later step is recorded', () => { + const nodes = [ + assistant({ seq: 2, turn: 1, step: 1 }), + assistant({ + seq: 4, turn: 1, step: 2, + timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 }, + usage: { outputTokens: 30 }, + }), + ] + expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 }) + }) + + it('omits turns with no readings and zero-decode throughput', () => { + const nodes = [ + assistant({ seq: 2, turn: 1, step: 1 }), + assistant({ + seq: 4, turn: 2, step: 1, + timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 }, + usage: { outputTokens: 10 }, + }), + ] + expect(deriveTurnMetrics(nodes).size).toBe(0) + }) + + it('keeps turns independent and ignores non-assistant nodes', () => { + const nodes: ConversationNode[] = [ + user(1), + assistant({ + seq: 2, turn: 1, step: 1, + timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 }, + usage: { outputTokens: 10 }, + }), + user(3), + assistant({ + seq: 4, turn: 2, step: 1, + timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 }, + usage: { outputTokens: 100 }, + }), + ] + const metrics = deriveTurnMetrics(nodes) + expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 }) + expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 }) + }) +}) + +describe('footer figure formatters', () => { + it('formats latency with one decimal under ten seconds and whole seconds beyond', () => { + expect(formatLatencySeconds(840)).toBe('0.8') + expect(formatLatencySeconds(1_000)).toBe('1') + expect(formatLatencySeconds(9_949)).toBe('9.9') + expect(formatLatencySeconds(12_400)).toBe('12') + expect(formatLatencySeconds(-5)).toBe('0') + }) + + it('formats throughput with whole tokens from ten up and one decimal below', () => { + expect(formatTokensPerSecond(34.4)).toBe('34') + expect(formatTokensPerSecond(9.96)).toBe('10') + expect(formatTokensPerSecond(3.14)).toBe('3.1') + expect(formatTokensPerSecond(-1)).toBe('0') + }) +}) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 8940f5dd24..220661ff88 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -272,6 +272,10 @@ describe('web toolview registration', () => { const registered: { key: string; locale: unknown; component: unknown }[] = [] const ctx = { slots: { + inject: (_name: string, callback: () => Iterable<() => void>) => { + for (const _dispose of callback()) { /* exhaust transactional setup */ } + return () => undefined + }, register: (options: { name: string; key: string; locale?: string }, component: unknown) => { registered.push({ key: options.key, locale: options.locale, component }) return () => {} @@ -285,7 +289,6 @@ describe('web toolview registration', () => { // One component under both keys, not two thin rows. expect(registered[0]?.component).toBe(WebRow) expect(registered[1]?.component).toBe(WebRow) - // The load-order seam the render site depends on. - expect(webToolview.inject).toEqual(['slots', 'conversation']) + expect(webToolview.inject).toEqual(['slots']) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 5a426916bd..5120d720cd 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33 -README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf +README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde +README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 3da9d97c80..0ea00b8bf9 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -8,12 +8,12 @@ The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar ## Model Experience -Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content. +Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. #### KV Cache effect -None beyond the goal mutation's own context event, which appends to the log tail like any other message. +None unless the queued goal context is admitted. An admitted context extends the history tail like any other message; an insertion discarded before admission does not affect the cache. ## Known Limitations and Deferred Work -- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it. +- **Durable phase only** — the projection omits process-local activation, so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. There is no host-live activation channel. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index c2474fc6ef..70bf443118 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,18 +2,18 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 -`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 +`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 -## Model Experience +## 模型体验 -间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。 +间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 -#### KV Cache effect +#### KV Cache 影响 -除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响。 +除非已排队的 goal 上下文获准,否则没有影响。获准的上下文会像其他消息一样扩展历史尾部;准入前被丢弃的插入项不会影响缓存。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 -- **只反映持久 phase** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。 +- **只反映持久 phase**——投影省略进程本地 activation,因此条带无法区分 active-but-disarmed 与 armed 状态;resume 通过 RPC 重新置为 armed 状态。不存在 host 实时 activation 通道。 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 98e22fd9d2..9430da812f 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -67,8 +67,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 62249947c7..ffc3149cce 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,5 +1,6 @@ /* GoalBar: the second standalone card in the composer context stack (Figma - 1236:32276). Its 752px column matches Todo and the Queue panel. */ + 1236:32276). Its dock column (card cap minus four insets) matches Todo and + the Queue panel. */ .dock { box-sizing: border-box; @@ -21,27 +22,29 @@ align-items: center; gap: 10px; width: 100%; - max-width: 752px; + max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset)); height: 36px; margin: 0 auto; padding: 4px 5px 4px 12px; border: 1px solid var(--dsw-alias-border-l1); - border-radius: 14px; + border-radius: 12px; background: var(--dsw-specific-tip); } -.sparkle { +.goalGlyph { display: inline-flex; flex: none; color: var(--dsw-alias-label-tertiary); } +/* Matches the Todo/Queue panel titles (13/24 medium, primary) so the three + composer-stack cards read as one family. */ .label { flex: none; font-size: 13px; - line-height: 20px; + line-height: 24px; font-weight: 500; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-primary); } .objective { diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index ca569dff6d..f7a9050597 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -1,6 +1,6 @@ /** * GoalBar: the goal indicator docked above the message composer (input dock - * strip). A present goal shows a sparkle, a phase label, the truncated + * strip). A present goal shows a goal glyph, a phase label, the truncated * objective, and icon actions — resume when paused, edit (inline form in the * same strip), and clear. Goal creation lives on the `/goal` command, not * here: loading (undefined), no goal (null), and complete goals render @@ -11,7 +11,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconGoalOutline16, + IconPauseOutline16, IconPlayOutline16, IconTrashOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { GoalActionResult, GoalBarActions } from './slots.ts' @@ -94,26 +95,28 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar /> {actionError !== null && <span className={css.error} role="alert">{actionError}</span>} <div className={css.actions}> - <button - type="button" - className={css.iconBtn} - onClick={() => { void handleEdit() }} - disabled={pending || draft.trim() === ''} - title={t('action.save')} - aria-label={t('action.save')} - > - <IconCheckOutline16 /> - </button> - <button - type="button" - className={css.iconBtn} - onClick={() => { setEditing(false) }} - disabled={pending} - title={t('action.cancel')} - aria-label={t('action.cancel')} - > - <IconCloseOutline16 /> - </button> + <Tooltip label={t('action.save')} side="bottom" delayMs={500}> + <button + type="button" + className={css.iconBtn} + onClick={() => { void handleEdit() }} + disabled={pending || draft.trim() === ''} + aria-label={t('action.save')} + > + <IconCheckOutline16 size={14} /> + </button> + </Tooltip> + <Tooltip label={t('action.cancel')} side="bottom" delayMs={500}> + <button + type="button" + className={css.iconBtn} + onClick={() => { setEditing(false) }} + disabled={pending} + aria-label={t('action.cancel')} + > + <IconCloseOutline16 size={14} /> + </button> + </Tooltip> </div> </div> </div> @@ -124,34 +127,41 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar return ( <div className={css.dock} data-goal-bar> <div className={css.bar} title={title}> - <span className={css.sparkle}><IconSparkle16 /></span> + <span className={css.goalGlyph}><IconGoalOutline16 size={14} /></span> <span className={css.label}>{t(PHASE_LABELS[goal.phase])}</span> <span className={css.objective}>{goal.objective}</span> {actionError !== null && <span className={css.error} role="alert">{actionError}</span>} <div className={css.actions}> {goal.phase === 'active' && ( - <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} title={t('action.pause')} aria-label={t('action.pause')}> - <IconPauseOutline16 /> - </button> + <Tooltip label={t('action.pause')} side="bottom" delayMs={500}> + <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} aria-label={t('action.pause')}> + <IconPauseOutline16 size={14} /> + </button> + </Tooltip> )} {goal.phase === 'paused' && ( - <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title={t('action.resume')} aria-label={t('action.resume')}> - <IconPlayOutline16 /> - </button> + <Tooltip label={t('action.resume')} side="bottom" delayMs={500}> + <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} aria-label={t('action.resume')}> + <IconPlayOutline16 size={14} /> + </button> + </Tooltip> )} - <button - type="button" - className={css.iconBtn} - disabled={pending} - onClick={() => { setDraft(goal.objective); setEditing(true) }} - title={t('action.edit')} - aria-label={t('action.edit')} - > - <IconEditOutline16 /> - </button> - <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} title={t('action.clear')} aria-label={t('action.clear')}> - <IconTrashOutline16 /> - </button> + <Tooltip label={t('action.edit')} side="bottom" delayMs={500}> + <button + type="button" + className={css.iconBtn} + disabled={pending} + onClick={() => { setDraft(goal.objective); setEditing(true) }} + aria-label={t('action.edit')} + > + <IconEditOutline16 size={14} /> + </button> + </Tooltip> + <Tooltip label={t('action.clear')} side="bottom" delayMs={500}> + <button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} aria-label={t('action.clear')}> + <IconTrashOutline16 size={14} /> + </button> + </Tooltip> </div> </div> </div> diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 3cd5c05711..6ee340715c 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -53,52 +53,47 @@ export function apply(ctx: ClientContext): void { const { goals } = (ctx.get('connection') as ConnectionHandle).api - // Conditional mount: 'conversation.input.dock' is declared by the - // conversation entry; the conversation service being up is the - // registration-safe signal (the TodoDock/QueueDock seam). - ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => { - const sessions = scope.sessions + const sessions = ctx.sessions - /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ - const refOf = (sessionId: SessionId): GoalRef | undefined => { - const face = sessions.binding(sessionId)?.session.projections.faceOf('goal') - const projection = face?.getSnapshot() as GoalProjection | null | undefined - if (projection == null) return undefined - return { id: projection.goal.id, revision: projection.goal.revision } - } + /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ + const refOf = (sessionId: SessionId): GoalRef | undefined => { + const face = sessions.binding(sessionId)?.session.projections.faceOf('goal') + const projection = face?.getSnapshot() as GoalProjection | null | undefined + if (projection == null) return undefined + return { id: projection.goal.id, revision: projection.goal.revision } + } - const noCurrentGoal: GoalActionResult = { - ok: false, - error: { code: 'no-current-goal', message: 'no current goal to mutate' }, - } + const noCurrentGoal: GoalActionResult = { + ok: false, + error: { code: 'no-current-goal', message: 'no current goal to mutate' }, + } - scope.effect(() => scope.slots.register({ - name: 'conversation.input.dock', - id: 'goal', - order: 10, - locale: NS, - inject: (sessionId): GoalBarActions => ({ - onEdit: async (objective) => { - const ref = refOf(sessionId) - if (ref === undefined) return noCurrentGoal - return settle((await goals.edit({ sessionId, ref, objective })).result) - }, - onPause: async () => { - const ref = refOf(sessionId) - if (ref === undefined) return noCurrentGoal - return settle((await goals.pause({ sessionId, ref })).result) - }, - onResume: async () => { - const ref = refOf(sessionId) - if (ref === undefined) return noCurrentGoal - return settle((await goals.resume({ sessionId, ref })).result) - }, - onClear: async () => { - const ref = refOf(sessionId) - if (ref === undefined) return noCurrentGoal - return settle((await goals.clear({ sessionId, ref })).result) - }, - }), - }, GoalDock), 'ui-goal: GoalBar dock registration') - }) + ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ + name: 'conversation.input.dock', + id: 'goal', + order: 10, + locale: NS, + inject: (sessionId): GoalBarActions => ({ + onEdit: async (objective) => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.edit({ sessionId, ref, objective })).result) + }, + onPause: async () => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.pause({ sessionId, ref })).result) + }, + onResume: async () => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.resume({ sessionId, ref })).result) + }, + onClear: async () => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.clear({ sessionId, ref })).result) + }, + }), + }, GoalDock)) } diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 796f0fd927..98d5a0291a 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -14,7 +14,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -45,7 +45,7 @@ function makeProjection(revision = 3): GoalProjection { } /** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */ -function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { +async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { const ctx = new Context() const calls: { method: string; payload: unknown }[] = [] function answer<T>(method: string, value: T) { @@ -65,14 +65,10 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi resume: answer('goal.resume', { ref }), clear: answer('goal.clear', { cleared: true as const }), } } }) - const entries = new Map<string, { id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }>() - ctx.provide('slots', { - register(reg: { name: string; id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }) { - entries.set(reg.name, reg) - return () => { entries.delete(reg.name) } - }, - }) - ctx.provide('conversation', {}) + await ctx.plugin(SlotsService).await() + ctx.slots.register({ + name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, + } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) ctx.provide('sessions', { binding: (id: SessionId) => ({ @@ -89,20 +85,28 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi ctx, fiber, calls, - entry: () => entries.get('conversation.input.dock'), + entry: () => { + const entry = ctx.slots.entries('conversation.input.dock')[0] + if (entry === undefined) return undefined + return { + ...entry.options, + locale: entry.locale, + inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined, + } + }, } } describe('ui-goal browser plugin', () => { it('registers the GoalBar dock entry with the documented id and order', async () => { - const b = bench() + const b = await bench() await b.fiber.await() expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' }) expect(b.entry()?.inject).toBeTypeOf('function') }) it('verbs read the CAS ref from the current projected value at call time', async () => { - const b = bench({ projection: makeProjection(5) }) + const b = await bench({ projection: makeProjection(5) }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) expect(await verbs.onEdit('New objective')).toEqual({ ok: true }) @@ -119,7 +123,7 @@ describe('ui-goal browser plugin', () => { it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { - const b = bench({ projection }) + const b = await bench({ projection }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { @@ -130,14 +134,14 @@ describe('ui-goal browser plugin', () => { }) it('maps a settled RPC error onto the inline-render shape', async () => { - const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) + const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) }) it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { - const b = bench() + const b = await bench() await b.fiber.await() expect(b.entry()).toBeDefined() await b.fiber.dispose() diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index efed54e19e..931c591000 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -52,7 +52,7 @@ describe('GoalBar', () => { expect(complete.container.firstChild).toBeNull() }) - it('active goal: sparkle, "进行中的目标", truncated objective, edit and clear actions', () => { + it('active goal: goal glyph, "进行中的目标", truncated objective, edit and clear actions', () => { const actions = makeActions() render(<GoalBar goal={makeGoal()} {...actions} t={t} />) expect(screen.getByText('进行中的目标')).toBeTruthy() diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index aba4711a24..8b5aff5db5 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba -README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3 +README.md: 5cb8f01efb2e18109e917225dbce088ea77394af +README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index cb99023e6a..5cb8f01efb 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -6,7 +6,7 @@ Shell plugin: three-column AppFrame (drag handles and concession chain) plus the AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. -The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`. +The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal. ## Model Experience @@ -20,4 +20,4 @@ None; this package neither assembles nor sends a provider request. - **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry. - **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth. -- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project. +- **No scroll anchoring during squeeze reflow** — layout changes may move the reader's viewport. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 3681b45176..6559fe595a 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 -AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。 +AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。 -`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。 +`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部。 ## 模型体验 @@ -19,5 +19,5 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr ## 已知限制与暂缓事项 - **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。 -- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。 -- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。 +- **让步链自动关闭通过推导零宽度实现,不会改动宽度偏好**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。 +- **挤压重排期间不提供滚动锚定**:布局变化可能移动读者的 viewport。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index b816533474..1a6e2785b6 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -56,8 +56,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 8aa16d8675..967066c056 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -13,7 +13,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { computeColumns } from './columns.ts' +import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -127,7 +127,19 @@ export function AppFrame({ } }, []) - const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details) + // Narrow viewports auto-collapse the sidebar; the store mirror keeps + // toggleSidebar's semantics right (narrow toggles flip the manual + // re-expand override, stores.ts). Collapsed is decided here, so the + // solver stays breakpoint-free: a narrow re-expand passes the preference + // (or the default when the wide preference is closed) and the center + // absorbs the squeeze. + const narrow = viewport < SIDEBAR_AUTO_COLLAPSE + useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow]) + const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 + const sidebarPreference = sidebarCollapsed + ? 0 + : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar + const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details) const colsRef = useRef(cols) colsRef.current = cols @@ -154,7 +166,7 @@ export function AppFrame({ ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={panels.sidebar === 0 || undefined} + data-sidebar-collapsed={sidebarCollapsed || undefined} data-details-collapsed={cols.details === 0 || undefined} data-dragging={dragging || undefined} > @@ -162,9 +174,10 @@ export function AppFrame({ {/* Render-site slot call with live concession output: a closed sidebar keeps the mounted slot at the compact-rail width, and the component sees its rendered state as owner params decided here - (collapsed follows the preference, not the resolved width). */} + (collapsed follows the resolved rail, so a derived auto-collapse + renders the rail UI too). */} {renderSlot('sidebar', { - collapsed: panels.sidebar === 0, + collapsed: sidebarCollapsed, width: cols.sidebar, })} </div> @@ -178,7 +191,7 @@ export function AppFrame({ <DetailsColumn>{renderSlot('details', {})}</DetailsColumn> </> {/* The collapsed rail is fixed-width: no resize handle while closed. */} - {panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />} + {!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />} {cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />} </div> ) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 125bb92a70..51a944ef2a 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -8,6 +8,9 @@ * deficit as the last resort. Inputs are the layout store's plain width * preferences (0 = closed); a closed sidebar resolves to the fixed * SIDEBAR_COLLAPSED control rail while closed details resolve to zero width. + * The SIDEBAR_AUTO_COLLAPSE breakpoint is consumed by AppFrame, which decides + * the effective sidebar preference before solving; the solver itself stays + * breakpoint-free. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -24,6 +27,10 @@ export const SIDEBAR_MAX = 420 export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 +/** Viewport width below which the sidebar auto-collapses to the rail (deepsuite + * LG breakpoint); a manual toggle below it re-expands over the squeezed center + * (stores.ts narrowExpanded). */ +export const SIDEBAR_AUTO_COLLAPSE = 1024 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index d2c7811381..d2de668a9c 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -13,8 +13,14 @@ import { SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, } from './columns.ts' -/** Layout store state: panel width preferences in px (0 = closed). */ -type LayoutState = { sidebar: number; details: number } +/** + * Layout store state: panel width preferences in px (0 = closed), plus the + * narrow-viewport pair — `narrow` mirrors AppFrame's breakpoint reading + * (viewport < SIDEBAR_AUTO_COLLAPSE) so toggleSidebar can pick semantics, and + * `narrowExpanded` is the manual override that re-expands the auto-collapsed + * sidebar over the squeezed center without rewriting the width preference. + */ +type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean } /** * Annotation twin of the actions literal below (the export needs a declared @@ -24,6 +30,7 @@ type LayoutActions = { setSidebar: (draft: LayoutState, px: number) => void setDetails: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void + setNarrow: (draft: LayoutState, narrow: boolean) => void openDetails: (draft: LayoutState) => void closeDetails: (draft: LayoutState) => void } @@ -33,16 +40,30 @@ type LayoutActions = { * closing a panel forgets its drag width — reopening restores the contract * default. Actions are the complete write set: drag writes clamp * into the panel's contract range and never cross the open/closed line; - * open/close transitions write 0 / the default explicitly. + * open/close transitions write 0 / the default explicitly. Below the + * auto-collapse breakpoint (AppFrame feeds setNarrow) the sidebar toggle + * flips the narrowExpanded override instead of the preference. * @returns the store handle (spec + type + identity + factory in one). */ export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> { const handle = defineStore({ - init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), + init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }), actions: { setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) }, - toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, + // Narrow toggles flip only the override: the width preference survives + // untouched, so re-widening restores the pre-squeeze layout. + toggleSidebar: (d) => { + if (d.narrow) d.narrowExpanded = !d.narrowExpanded + else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 + }, + // Crossing the breakpoint in either direction drops the override: the + // narrow default is auto-collapsed, the wide state is the preference. + setNarrow: (d, narrow: boolean) => { + if (d.narrow === narrow) return + d.narrow = narrow + d.narrowExpanded = false + }, openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT }, closeDetails: (d) => { d.details = 0 }, }, diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 11b5e48e0a..a8a988574b 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -284,6 +284,50 @@ describe('AppFrame', () => { }) }) +describe('AppFrame — narrow-viewport auto-collapse', () => { + it('mounts collapsed below the breakpoint with no sidebar handle', () => { + frameWidth = 980 + const { frame, slotCalls } = mountFrame() + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0) + }) + + it('narrow toggle re-expands over the squeezed center and back', () => { + frameWidth = 980 + const { frame, instance } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false) + expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + }) + + it('a wide-closed preference re-expands at the contract default while narrow', () => { + frameWidth = 1920 + const { frame, instance } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0 + frameWidth = 980 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched + }) + + it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => { + const { frame, instance } = mountFrame() + act(() => { instance.actions.setSidebar(400) }) + frameWidth = 980 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + frameWidth = 1920 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + expect(tracks(frame)).toEqual([400, 0]) + }) +}) + describe('AppFrame — guard branches', () => { it('pointer moves without capture are ignored (no width write)', () => { const { frame, instance } = mountFrame() diff --git a/packages/client/ui-layout/tests/layout-store.spec.ts b/packages/client/ui-layout/tests/layout-store.spec.ts index ddb3f4a5e1..c6f0069197 100644 --- a/packages/client/ui-layout/tests/layout-store.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.spec.ts @@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels' beforeEach(() => { localStorage.clear() }) describe('createLayoutStore', () => { - it('initializes the sidebar at its default width and details closed', () => { + it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => { const { store } = createLayoutStore().create() - expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 }) + expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }) }) it('each create() is an independent instance (factory is not a singleton)', () => { @@ -50,6 +50,30 @@ describe('createLayoutStore', () => { expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT) }) + it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.setNarrow(true) + actions.toggleSidebar() + expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true }) + actions.toggleSidebar() + expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().sidebar).toBe(400) + }) + + it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => { + const { store, actions } = createLayoutStore().create() + actions.setNarrow(true) + actions.toggleSidebar() + expect(store.getSnapshot().narrowExpanded).toBe(true) + actions.setNarrow(true) + expect(store.getSnapshot().narrowExpanded).toBe(true) + actions.setNarrow(false) + expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false }) + actions.setNarrow(true) + expect(store.getSnapshot().narrowExpanded).toBe(false) + }) + it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => { const { store, actions } = createLayoutStore().create() actions.openDetails() @@ -72,6 +96,8 @@ describe('createLayoutStore', () => { expect(second.store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, + narrow: false, + narrowExpanded: false, }) }) }) diff --git a/packages/client/ui-layout/tests/service.spec.ts b/packages/client/ui-layout/tests/service.spec.ts index a01f39b810..c7c9cca5ba 100644 --- a/packages/client/ui-layout/tests/service.spec.ts +++ b/packages/client/ui-layout/tests/service.spec.ts @@ -13,6 +13,7 @@ function fakePanels(): PanelActions { setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), + setNarrow: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), } diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 1a4e421a7f..69ec84f418 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model/README.md -README.md: 27fb7b936b796b956f7348fa776856180350bb56 -README.zh.md: 9cc6b04ef2e7ba24fb8fc3f6d5456bf88f0652fe +README.md: 5f9fc65939eb747d916fa5609423d3186d1fefde +README.zh.md: 3ed8db3095d96e48813cf5b15a206ebf4c894950 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 27fb7b936b..5f9fc65939 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,7 +2,11 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. + +The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. + +Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 9cc6b04ef2..3ed8db3095 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,13 +2,17 @@ [English](README.md) | 中文 -模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 +模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。 -`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 +Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。 + +目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 + +`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。 ## 模型体验 -间接影响,经仅普通会话可用的 `session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标,Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标;只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。 +间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交提供方/模型/推理强度目标;Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。 #### KV Cache 影响 @@ -16,6 +20,6 @@ ## 已知限制与暂缓事项 -- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可折入会话创建的 Draft 期模型选择,subagent 继续执行也有意不公开独立更改模型目标的契约。 +- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立更改模型目标的契约。 - **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。 - **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。 diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 2e9a199805..6be5dcbc43 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -68,8 +68,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index f9d6cc10e6..b19242fb44 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -15,7 +15,8 @@ height: 28px; padding: 0 4px 0 8px; border: none; - border-radius: 8px; + /* Rounded chip chrome, matching the sibling permission trigger. */ + border-radius: 24px; outline: none; background: transparent; color: var(--dsw-alias-label-secondary); @@ -197,8 +198,7 @@ white-space: nowrap; } -.description, -.unlisted { +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -207,10 +207,6 @@ white-space: nowrap; } -.unlisted { - color: var(--dsw-alias-state-warn-label); -} - .check { display: grid; place-items: center; diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 2ad7159044..343243a28c 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -174,8 +174,13 @@ export function ModelSelect( }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') + const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` + const triggerAria = currentChoice === undefined + ? t('trigger.selectAria') + : effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }) itemRefs.current = [] let itemIndex = 0 const itemRef = () => { @@ -189,9 +194,7 @@ export function ModelSelect( ref={triggerRef} type="button" className={css.trigger} - aria-label={effortLabel === undefined - ? t('trigger.aria', { model: modelLabel }) - : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} + aria-label={triggerAria} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -277,9 +280,6 @@ export function ModelSelect( {model.description !== undefined && ( <span className={css.description}>{model.description}</span> )} - {model.unlisted === true && ( - <span className={css.unlisted}>{t('option.currentUnlisted')}</span> - )} </span> <span className={css.check}> {selected ? <IconCheckOutline16 /> : null} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index 86091f4ede..0dde4778e1 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -51,9 +51,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: model.unlisted === true - ? t('option.unlisted', { group: group.name }) - : model.description !== undefined ? `${group.name} · ${model.description}` : group.name, + detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name, ...(directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}), }) @@ -150,12 +148,10 @@ export function apply(ctx: ClientContext): void { }) // Entry 2: the composer's named model seat over the SAME directory. - // Conditional mount: the seat is declared by the composer-bar entry; the - // conversation service's presence is the registration-safe signal. - ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => { + ctx.inject(['slots', 'models'], (scope: ClientContext) => { const models = scope.models const sessions = scope.sessions - scope.effect(() => scope.slots.register({ + scope.slots.inject('conversation.input.model', () => scope.slots.register({ name: 'conversation.input.model', locale: NS, inject: (sessionId): ModelSelectInjected => { @@ -172,6 +168,6 @@ export function apply(ctx: ClientContext): void { : Promise.resolve(false), } }, - }, ModelSelect), 'ui-model: composer model seat registration') + }, ModelSelect)) }) } diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index 856c95e470..f2b8bf1f01 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -1,11 +1,19 @@ -/** `model` namespace dictionaries. */ +/** + * `model` namespace dictionaries. + * + * `trigger.selectAria` reads identically to `trigger.fallback` today and is + * still a separate key: the visible fallback label and the accessible name of + * an unset trigger are free to diverge per locale, and folding it into + * `trigger.aria` would announce the degenerate "Select model, current Select + * model". + */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'command.description': '选择本会话使用的模型', - 'option.unlisted': '{group} · 未列入目录', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', + 'trigger.selectAria': '选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', @@ -16,7 +24,6 @@ export const zh = { 'error.action': '模型操作失败:{message}', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', - 'option.currentUnlisted': '当前模型 · 未列入目录', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record<string, string> @@ -27,9 +34,9 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'command.description': 'Select the model for this conversation', - 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', 'trigger.fallback': 'Select model', + 'trigger.selectAria': 'Select model', 'trigger.aria': 'Select model, current {model}', 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', 'menu.aria': 'Model and reasoning effort', @@ -40,7 +47,6 @@ export const en = { 'error.action': 'Model operation failed: {message}', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', - 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record<ModelKey, string> diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 9a9e58ce00..5f3ac64add 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -85,12 +85,12 @@ async function bench() { locale: string | undefined }>() ctx.provide('slots', { + inject(_name: string, callback: () => () => void) { return callback() }, register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) { seats.set(options.name, { inject: options.inject, locale: options.locale }) return () => { seats.delete(options.name) } }, }) - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) const scopes = new Map<SessionId, Context>() const addressed = new Set<SessionId>() diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 61a8f6edd0..45df8ab38e 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -111,6 +111,29 @@ describe('ModelSelect reasoning effort', () => { .toEqual(['Default', 'Standard']) }) + it('prompts for a new selection when the current target is no longer advertised', () => { + const directory = createSnapshotStore(state({ + current: { provider: 'deepseek-official', model: 'removed-model' }, + })) + const select = vi.fn().mockResolvedValue(true) + render(<ModelSelect + locked={false} + available + directory={directory} + load={vi.fn()} + select={select} + t={t} + />) + + const trigger = screen.getByRole('button', { name: '选择模型' }) + expect(trigger.textContent).toContain('选择模型') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull() + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByText('removed-model')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + }) + it('renders no Agent-bound control for an addressed subagent session', () => { const load = vi.fn() render(<ModelSelect diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ac9bc641e9..2e4cf00248 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767 -README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246 +README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89 +README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 937b8e6bf9..c578ecfc91 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. -- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 37d8642e8d..40da5b52f6 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -16,11 +16,10 @@ #### KV Cache 影响 -无;该包(package)既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供方请求。 ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 -- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index 025cc4c440..abe8dbc0f5 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -65,8 +65,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx new file mode 100644 index 0000000000..57221cc93a --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -0,0 +1,364 @@ +/** + * Curated editor for the direct DeepSeek adapter's advisory model catalog. + * The settings layer replaces `models` as one array, so the parent supplies + * the effective inherited rows until the first edit materializes a user + * override; reset removes that override instead of copying defaults into it. + */ + +import { useState } from 'react' +import type { ReactNode } from 'react' +import { + IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** One catalog entry kept structurally open so hidden or future fields survive an edit. */ +export type DeepSeekModelDraft = Record<string, unknown> + +/** The catalog fields this editor writes. */ +type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens' + +/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ +type CapacityField = 'contextWindow' | 'maxTokens' + +/** Row index encoded in an editing-buffer key. */ +function rowOf(key: string): number { + return Number(key.slice(0, key.indexOf(':'))) +} + +/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */ +const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i + +/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */ +const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const + +/** + * Read a typed capacity, so a user can write `256K` or `1M` instead of counting + * zeroes. The stored value stays a plain token count. + * @param text - raw field text. + * @returns the count; `undefined` when blank (inherit), `NaN` when unreadable + * (rejected by {@link validateDeepSeekModels} before any write). + */ +export function parseCapacity(text: string): number | undefined { + const trimmed = text.trim() + if (trimmed.length === 0) return undefined + const match = CAPACITY_PATTERN.exec(trimmed) + if (match === null) return Number.NaN + const suffix = match[2]?.toLowerCase() + const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1 + const scaled = Number(match[1]) * scale + // A decimal multiple is exact in intent but not in binary floating point + // (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back. + const rounded = Math.round(scaled) + return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled +} + +/** + * Spell a stored count back in the shortest form that survives a round trip + * through {@link parseCapacity}; a count that is not a whole number of + * thousands stays written out. + * @param value - stored capacity. + * @returns the field text. + */ +export function formatCapacity(value: number): string { + if (!Number.isInteger(value) || value <= 0) return String(value) + if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M` + if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K` + return String(value) +} + +/** A localized validation failure for one user-owned model array. */ +export interface DeepSeekModelsValidationFailure { + /** Zero-based model position. */ + index: number + /** Message key owned by the Models settings section. */ + key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid' + | 'modelMaxTokensInvalid' +} + +/** Convert a schema-validated catalog value into records without dropping hidden fields. */ +export function modelDrafts(value: unknown): DeepSeekModelDraft[] { + if (!Array.isArray(value)) return [] + return value.map(entry => + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ? entry as DeepSeekModelDraft + : {}) +} + +/** + * Validate adapter constraints that the serialized schema cannot express. + * @param value - user-owned `models` value, or undefined while inherited. + * @returns the first invalid row, or undefined when the adapter will accept it. + */ +export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined { + if (value === undefined) return undefined + const models = modelDrafts(value) + const seen = new Set<string>() + for (const [index, model] of models.entries()) { + // Compared trimmed: surrounding whitespace is a paste artifact the adapter + // would never match, and an untrimmed compare lets `model ` slip past the + // duplicate check against its own twin. + const id = model['id'] + const trimmed = typeof id === 'string' ? id.trim() : undefined + if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' } + if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' } + seen.add(trimmed) + const name = model['name'] + if (name !== undefined && (typeof name !== 'string' || name.length === 0)) { + return { index, key: 'modelNameInvalid' } + } + const contextWindow = model['contextWindow'] + if (contextWindow !== undefined + && (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) { + return { index, key: 'modelContextInvalid' } + } + const maxTokens = model['maxTokens'] + if (maxTokens !== undefined + && (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) { + return { index, key: 'modelMaxTokensInvalid' } + } + } + return undefined +} + +/** Props of {@link DeepSeekModelsEditor}. */ +export interface DeepSeekModelsEditorProps { + /** Effective rows: inherited until the parent materializes an override. */ + models: readonly DeepSeekModelDraft[] + /** Whether the user layer currently owns the whole array. */ + overridden: boolean + /** Fallback context capacity used when a row omits its exact value. */ + defaultContextWindow: number | undefined + /** Fallback output cap used when a row omits its exact value. */ + defaultMaxTokens: number | undefined + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable every mutation. */ + disabled: boolean + /** Replace the user-owned array after one visible edit. */ + onChange: (models: DeepSeekModelDraft[]) => void + /** Remove the user-owned array and return to inheritance. */ + onReset: () => void +} + +/** + * Render the direct DeepSeek adapter's model catalog: id and display name on + * each row, capacities behind the row's own disclosure. + * @param props - effective rows plus the array-level override actions. + * @returns the catalog editor. + */ +export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { + // Capacities are edited as text, so a field's keystrokes are held here + // rather than re-derived from the parsed count on every change, which would + // rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the + // save-time rejection names a row the user can still see — which is why + // this is one entry PER FIELD: a single active buffer would be displaced by + // editing any other field, and the abandoned one would fall back to + // rendering its stored NaN as the literal `NaN`. + // + // Keys carry the row index, so the two operations that move indexes maintain + // them: `remove` re-keys around the dropped row, and reset clears them all + // because the rows they annotated are gone. + const [editing, setEditing] = useState<ReadonlyMap<string, string>>(() => new Map()) + const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set()) + + const update = (index: number, key: CatalogField, value: unknown): void => { + const next = props.models.map((model, at) => { + const copy = { ...model } + if (at !== index) return copy + if (value === undefined) Reflect.deleteProperty(copy, key) + else copy[key] = value + return copy + }) + props.onChange(next) + } + + const remove = (index: number): void => { + setEditing((current) => { + const next = new Map<string, string>() + for (const [key, text] of current) { + const at = rowOf(key) + if (at === index) continue + // Only the row number moves; the field half of the key is untouched. + next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text) + } + return next + }) + setExpanded((current) => { + const next = new Set<number>() + for (const at of current) { + if (at === index) continue + next.add(at > index ? at - 1 : at) + } + return next + }) + props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) + } + + const reset = (): void => { + setEditing(new Map()) + setExpanded(new Set()) + props.onReset() + } + + const toggle = (index: number): void => { + setExpanded((current) => { + const next = new Set(current) + if (!next.delete(index)) next.add(index) + return next + }) + } + + /** The field's text: its live keystrokes, else the stored count spelled short. */ + const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => { + const typed = editing.get(`${String(index)}:${field}`) + if (typed !== undefined) return typed + const value = model[field] + return typeof value === 'number' ? formatCapacity(value) : '' + } + + const settleCapacity = (index: number, field: CapacityField): void => { + const key = `${String(index)}:${field}` + const typed = editing.get(key) + if (typed === undefined) return + // Unreadable text stays on screen: the save-time rejection names a row the + // user can still see and correct. + const parsed = parseCapacity(typed) + if (parsed !== undefined && Number.isNaN(parsed)) return + setEditing((current) => { + const next = new Map(current) + next.delete(key) + return next + }) + } + + /** One capacity field of one row, rendered inside the row's disclosure. */ + const capacityField = ( + model: DeepSeekModelDraft, + index: number, + field: CapacityField, + fallback: number | undefined, + ): ReactNode => ( + <label className={styles['modelField']}> + <span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span> + <input + className={styles['input']} + type="text" + inputMode="numeric" + value={capacityText(model, index, field)} + placeholder={fallback === undefined + ? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder') + : formatCapacity(fallback)} + aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`} + disabled={props.disabled} + onChange={(event) => { + const text = event.target.value + setEditing(current => new Map(current).set(`${String(index)}:${field}`, text)) + update(index, field, parseCapacity(text)) + }} + onBlur={() => { settleCapacity(index, field) }} + /> + </label> + ) + + return ( + <section className={styles['modelCatalog']} aria-label={props.t('models')}> + <div className={styles['modelListHead']}> + <div className={styles['modelCatalogHeading']}> + <span className={styles['modelCatalogTitle']}>{props.t('models')}</span> + <span className={styles['modelCatalogMeta']}> + {props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')} + </span> + </div> + {props.overridden + ? ( + <button + type="button" + className={styles['linkButton']} + disabled={props.disabled} + onClick={reset} + > + {props.t('resetModels')} + </button> + ) + : null} + </div> + {props.models.length === 0 + ? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p> + : ( + <div className={styles['modelList']}> + {props.models.map((model, index) => ( + <div className={styles['modelEntry']} key={index}> + <div className={styles['modelRow']}> + <input + className={styles['input']} + type="text" + value={typeof model['id'] === 'string' ? model['id'] : ''} + placeholder={props.t('modelId')} + aria-label={`${props.t('modelId')} ${String(index + 1)}`} + disabled={props.disabled} + onChange={(event) => { update(index, 'id', event.target.value) }} + onBlur={(event) => { + // Settle a pasted id rather than trimming per keystroke, + // which would stop the user typing an interior space. + const trimmed = event.target.value.trim() + if (trimmed !== event.target.value) update(index, 'id', trimmed) + }} + /> + <input + className={styles['input']} + type="text" + value={typeof model['name'] === 'string' ? model['name'] : ''} + placeholder={props.t('modelName')} + aria-label={`${props.t('modelName')} ${String(index + 1)}`} + disabled={props.disabled} + onChange={(event) => { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + <button + type="button" + className={styles['iconButton']} + aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`} + aria-expanded={expanded.has(index)} + title={props.t('modelAdvanced')} + onClick={() => { toggle(index) }} + > + {expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />} + </button> + <button + type="button" + className={`${styles['iconButton']} ${styles['iconButtonDanger']}`} + aria-label={`${props.t('removeModel')} ${String(index + 1)}`} + title={props.t('removeModel')} + disabled={props.disabled} + onClick={() => { remove(index) }} + > + <IconTrashOutline16 size={14} /> + </button> + </div> + {expanded.has(index) + ? ( + <div className={styles['modelAdvanced']}> + {capacityField(model, index, 'contextWindow', props.defaultContextWindow)} + {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)} + </div> + ) + : null} + </div> + ))} + </div> + )} + <button + type="button" + className={styles['addModelButton']} + disabled={props.disabled} + onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }} + > + <IconPlusOutline16 size={14} /> + {props.t('addModel')} + </button> + </section> + ) +} diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a8b28db46c..6b87dbefe3 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -1,3 +1,13 @@ +/* Models settings section, in the settings-panel design language: 14/22 body, + * 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense), + * 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and + * the Button/Input primitives already use. + * + * Every color resolves through a `--dsw-alias-*` token. The section used to + * name `--border` / `--surface` / `--text-*`, which nothing in this app + * defines, so it always rendered the light-mode literals written as their + * fallbacks and stayed light under the dark theme. */ + .section { display: flex; flex-direction: column; @@ -8,31 +18,38 @@ .title { margin: 0; - font-size: 18px; - font-weight: 600; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .intro { margin: 0; - font-size: 13px; + font-size: 14px; + line-height: 22px; color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-state-warn-label); } .rows { list-style: none; - margin: 0; + /* Extra air between the title/intro block and the first provider card. */ + margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } +/* A configured provider: outlined on the panel fill, so the filled editor + card it expands into reads as the nested object. */ .rowCard { border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; @@ -40,7 +57,6 @@ display: flex; flex-direction: column; gap: 12px; - background: var(--dsw-alias-bg-layer-3); } .rowHead { @@ -50,55 +66,123 @@ } .rowName { - font-size: 15px; - font-weight: 600; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .rowActions { display: inline-flex; - gap: 8px; + align-items: center; + gap: 4px; margin-left: auto; } -.primaryButton { +/* `box-sizing` on every control here: the app has no global border-box reset, + so without it the outlined variants stand 2px taller than the filled ones + they sit beside (Cancel next to Apply, Edit next to Delete). */ +.primaryButton, +.secondaryButton, +.addButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 36px; + padding: 0 14px; border: none; - border-radius: 999px; - padding: 8px 18px; - background: var(--dsw-alias-button-primary-fill); - color: var(--dsw-alias-label-primary-foreground); + border-radius: 18px; font: inherit; + font-size: 14px; + line-height: 22px; cursor: pointer; } -.secondaryButton { +.primaryButton { + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} + +.primaryButton:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); +} + +.secondaryButton, +.addButton { border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 6px 14px; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - font: inherit; - cursor: pointer; + background: transparent; + color: var(--dsw-alias-label-primary); +} + +.secondaryButton:hover:not(:disabled), +.addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.secondaryButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); } .dangerButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; border: none; - background: none; + border-radius: 18px; + background: transparent; color: var(--dsw-alias-state-error-primary); font: inherit; + font-size: 14px; + line-height: 22px; cursor: pointer; } +.dangerButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Provider-row controls take the dense capsule (Button `.sm`). */ +.rowActions .secondaryButton, +.rowActions .dangerButton { + height: 28px; + padding: 0 10px; + border-radius: 14px; + font-size: 12px; + line-height: 18px; +} + .primaryButton:disabled, .secondaryButton:disabled, -.dangerButton:disabled { - opacity: 0.5; +.dangerButton:disabled, +.addButton:disabled, +.linkButton:disabled, +.addModelButton:disabled { + opacity: 0.4; cursor: default; } +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.dangerButton:focus-visible, +.addButton:focus-visible, +.linkButton:focus-visible, +.addModelButton:focus-visible, +.iconButton:focus-visible, +.customizedSummary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +/* Editing surface: a filled module on the panel, matching the settings + selector fill rather than adding another outline inside the row. */ .editor { - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--dsw-alias-bg-layer-2); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -113,11 +197,14 @@ .editorTitle { font-size: 14px; - font-weight: 600; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .editorRoute { font-size: 12px; + line-height: 18px; color: var(--dsw-alias-label-tertiary); } @@ -132,29 +219,36 @@ align-items: center; gap: 10px; font-size: 12px; + line-height: 18px; font-weight: 500; color: var(--dsw-alias-label-secondary); } .linkButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; border: none; - background: none; - padding: 0; + border-radius: 14px; + background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; - text-decoration: underline; + line-height: 18px; cursor: pointer; } -.linkButton:disabled { - opacity: 0.5; - cursor: default; +.linkButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); } .advancedHint { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-label-tertiary); } @@ -171,27 +265,16 @@ } .addButton { + display: inline-flex; + align-items: center; + gap: 6px; align-self: flex-start; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - font-size: 13px; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - cursor: pointer; -} - -.addButton:disabled { - opacity: 0.5; - cursor: default; } .addCard, .setupCard { - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--dsw-alias-bg-layer-3); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -199,9 +282,9 @@ list-style: none; } +/* Nested in a card that already carries the module chrome. */ .addCard .editor, .setupCard .editor { - border: none; background: none; padding: 0; } @@ -211,12 +294,44 @@ padding-top: 10px; } +/* Native disclosure marker replaced by a rotating chevron: the built-in + triangle differs per engine and cannot take the label color. */ .customizedSummary { + display: flex; + align-items: center; + gap: 6px; + width: fit-content; + padding: 2px 4px; + margin-left: -4px; + border-radius: 6px; cursor: pointer; font-size: 12px; + line-height: 18px; font-weight: 500; color: var(--dsw-alias-label-secondary); - list-style: revert; + list-style: none; +} + +.customizedSummary::-webkit-details-marker { + display: none; +} + +.customizedSummary::before { + content: ''; + width: 5px; + height: 5px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + transform: rotate(-45deg) translate(-1px, -1px); + transition: transform 120ms ease; +} + +.customized[open] > .customizedSummary::before { + transform: rotate(45deg) translate(-1px, -1px); +} + +.customizedSummary:hover { + color: var(--dsw-alias-label-primary); } .customizedBody { @@ -226,17 +341,173 @@ padding-top: 12px; } +/* Model catalog: a table, not a stack of cards. The column captions are + written once above the rows, so a row is one line of fields plus its + delete control; each field still carries the indexed `aria-label` that + names it, and the caption strip is hidden from assistive tech to keep + that name from being announced twice. */ +.modelCatalog { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +.modelCatalogHeading { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modelCatalogTitle { + font-size: 12px; + line-height: 18px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.modelCatalogMeta, +.modelEmpty { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +/* Model list, shared with the pi-ai provider form (PR #1368): one bordered + entry per model, id and display name on the row, capacities behind the + row's own disclosure. The token names are this file's, not that branch's — + `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and + `--dsw-alias-text-primary` are undefined here and resolve to their + light-mode literals, which is the defect this section was just moved off. */ +.modelList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.modelListHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.modelEntry { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 6px; +} + +.modelRow { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; +} + +/* Square, label-free affordances: the row's own inputs carry the meaning, so + the actions stay glyphs and announce themselves through aria-label. */ +.iconButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.iconButton:disabled { + cursor: default; + opacity: 0.4; +} + +/* The delete glyph keeps the danger tint the rest of the section uses. */ +.iconButtonDanger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + +.modelAdvanced { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + padding: 8px 4px 2px; +} + +.modelField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modelFieldLabel { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.modelEmpty { + padding: 12px; + border: 1px dashed var(--dsw-alias-border-l3); + border-radius: 8px; + text-align: center; +} + +.addModelButton { + box-sizing: border-box; + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.addModelButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .input { box-sizing: border-box; - padding: 9px 12px; + width: 100%; + height: 32px; + padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 10px; + border-radius: 8px; font: inherit; - font-size: 13px; + font-size: 14px; + line-height: 22px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } +/* Enum pickers hold a handful of short options; a field-width dropdown reads + as a text field the user is expected to fill. */ +select.input { + max-width: 240px; + cursor: pointer; +} + .input:focus { outline: none; border-color: var(--dsw-alias-brand-primary); @@ -246,9 +517,29 @@ color: var(--dsw-alias-label-dimmed); } +.input:disabled { + opacity: 0.6; + cursor: default; +} + +/* Select variant of .input: replaces the OS arrow (which sits flush against + the right edge) with the shared 12px chevron inset like the composer's + .select chips; the right pad reserves its cell. */ +.selectInput { + appearance: none; + padding-right: 32px; + /* Data-URI SVGs cannot resolve CSS variables; #81858C is the caption gray + shared by both themes. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px 12px; +} + .error { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-state-error-primary); } @@ -264,3 +555,20 @@ .deleteConfirm:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); } + +/* Icon-button label seat: named for assistive tech and for the tests that + query these controls by their text. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .customizedSummary::before { + transition: none; + } +} diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index c206dbd864..b170df1fa0 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -12,7 +12,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -245,7 +245,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { <div className={styles['field']}> <span className={styles['fieldLabel']}>{t('provider')}</span> <select - className={styles['input']} + className={`${styles['input']} ${styles['selectInput']}`} value={addTarget.provider} aria-label={t('provider')} onChange={(event) => { @@ -287,7 +287,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { setEditing(targetOf(first)) }} > - {`+ ${t('add')}`} + {/* Same glyph as the composer's attach button. */} + <IconPlusOutline16 size={14} /> + {t('add')} </button> )} </div> diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 07b5dfae54..0f89f329c0 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -5,19 +5,23 @@ * under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile * has none, and the pi-ai profile records that derivation as `apiKeyEnv`); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, plus `reasoningEffort` for deepseek / `reasoning` for - * pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as - * minimal `settings.mutate` path ops against the stored section — the card - * reads the redacted descriptor, so it names only the fields it can see and a - * stored literal secret is never collaterally removed. + * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and + * DeepSeek's id/name/context-window model catalog). Everything else stays + * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` + * path ops against the stored section — the card reads the redacted + * descriptor, so it names only the fields it can see and a stored literal + * secret is never collaterally removed. */ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client' import { - deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, + deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '@deepseek-ai/dsh-client-schema-form' +import { + DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, +} from './DeepSeekModelsEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { && stringAt(fallback, 'apiKeyEnv') === undefined ? setPath(draft, ['apiKeyEnv'], keyRef) : draft + if (layout === 'deepseek') { + const modelFailure = validateDeepSeekModels(getPath(next, ['models'])) + if (modelFailure !== undefined) { + return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}` + } + } /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */ if (node !== undefined && settingsPath.length === 0) { const sectionError = validateDraft(node, next) @@ -229,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const keyLocked = keyState?.writable === false + /** + * The catalog beneath the user layer: what the composition entry pinned, or + * else the schema default that `resolve` would supply. The effective value + * cannot answer this — it still carries the stored override until the unset + * is applied, so reading it would echo that override straight back the + * moment reset drops it, leaving the rows unchanged until a reload. + */ + const inheritedModels = (): unknown => { + const pinned = getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + } + /** * The curated fields of one known adapter family. Taking the narrowed * family as a parameter is what makes `EFFORT_FIELD` total here: an @@ -236,6 +258,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { */ const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { const effortField = EFFORT_FIELD[family] + const customModels = getPath(draft, ['models']) + const modelsOverridden = hasPath(draft, ['models']) + const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) + const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = getPath(fallback, ['maxTokens']) return ( <> <div className={styles['field']}> @@ -275,7 +302,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { <div className={styles['field']}> <span className={styles['fieldLabel']}>{t('effort')}</span> <select - className={styles['input']} + className={`${styles['input']} ${styles['selectInput']}`} value={stringAt(draft, effortField) ?? ''} aria-label={t('effort')} disabled={disabled} @@ -289,6 +316,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ))} </select> </div> + {family === 'deepseek' + ? ( + <DeepSeekModelsEditor + models={models} + overridden={modelsOverridden} + defaultContextWindow={typeof defaultContextWindow === 'number' + ? defaultContextWindow + : undefined} + defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} + t={t} + disabled={disabled} + onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }} + onReset={() => { setDraft(current => deletePath(current, ['models'])) }} + /> + ) + : null} </div> </details> </> diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 685c180269..1de35aa342 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -6,7 +6,6 @@ * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). @@ -47,7 +46,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { /** * Required services (cordis fiber inject). The target slot is declared by * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. + * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = ['slots', 'locale', 'connection'] @@ -91,29 +90,17 @@ export function apply(ctx: ClientContext): void { return () => { for (const dispose of disposers) dispose() } }, 'ui-models: pushed invalidations') - ctx.effect(() => { - const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => - ctx.slots.register({ - name: 'settings.section', - id: 'models', - order: 10, - label: () => t('nav'), - inject: injected, - }, ModelsSection)) - const onboarding = deferRegistration( - ctx.slots, - 'settings.onboarding', - DeepSeekOnboardingDialog, - () => ctx.slots.register({ - name: 'settings.onboarding', - id: 'deepseek-official', - order: 0, - inject: onboardingInjected, - }, DeepSeekOnboardingDialog), - ) - return () => { - section.dispose() - onboarding.dispose() - } - }, 'ui-models: settings registrations') + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'models', + order: 10, + label: () => t('nav'), + inject: injected, + }, ModelsSection)) + ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ + name: 'settings.onboarding', + id: 'deepseek-official', + order: 0, + inject: onboardingInjected, + }, DeepSeekOnboardingDialog)) } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index c9491273bc..bb1254e46b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -30,6 +30,27 @@ export const en = { baseUrlDefault: 'Provider default', effort: 'Reasoning effort', effortInherit: 'Default', + models: 'Models', + modelsInherited: 'Using the adapter defaults', + modelsCustomized: 'Customized model catalog', + resetModels: 'Restore defaults', + model: 'Model', + modelId: 'Model ID', + modelName: 'Display name', + modelNamePlaceholder: 'Uses the model ID when empty', + contextWindow: 'Context window', + contextWindowPlaceholder: 'Uses the provider default', + maxTokens: 'Max output tokens', + maxTokensPlaceholder: 'Uses the provider default', + modelAdvanced: 'Capacities', + addModel: 'Add model', + removeModel: 'Delete model', + modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + modelIdRequired: 'Model ID is required.', + modelIdDuplicate: 'Model ID must be unique.', + modelNameInvalid: 'Display name cannot be empty.', + modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.', + modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.', advancedHint: 'Other fields live in settings.yaml; edit that section directly.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -70,6 +91,27 @@ export const zh: typeof en = { baseUrlDefault: '提供方默认', effort: '推理强度', effortInherit: '默认', + models: '模型目录', + modelsInherited: '正在使用适配器默认模型', + modelsCustomized: '已自定义模型目录', + resetModels: '恢复默认模型', + model: '模型', + modelId: '模型 ID', + modelName: '显示名称', + modelNamePlaceholder: '留空时使用模型 ID', + contextWindow: '上下文窗口', + contextWindowPlaceholder: '使用提供方默认值', + maxTokens: '最大输出 token 数', + maxTokensPlaceholder: '使用提供方默认值', + modelAdvanced: '容量', + addModel: '添加模型', + removeModel: '删除模型', + modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + modelIdRequired: '模型 ID 不能为空。', + modelIdDuplicate: '模型 ID 不能重复。', + modelNameInvalid: '显示名称不能为空。', + modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。', + modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 1f44cb487c..c668675be0 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Models section registration: declaration-aware deferral, the locale-following label thunk, and HMR recovery. */ +/** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index bf6eede131..aa9082e7dd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' +import { + DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, +} from '../src/client/DeepSeekModelsEditor.tsx' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -16,6 +19,16 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] +/** Open one row's capacity disclosure (1-based, as the labels read). */ +function expandRow(position: number): void { + fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`)) +} + +/** The capacity inputs of every open row, in row order. */ +function capacityInputs(label: string): HTMLInputElement[] { + return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label)) +} + const PiAiConfig = Schema.object({ token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ @@ -32,15 +45,54 @@ const DeepSeekConfig = Schema.object({ apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), + defaultContextWindow: Schema.number().step(1).min(1), + models: Schema.array(Schema.object({ + id: Schema.string().required(), + name: Schema.string(), + description: Schema.string(), + contextWindow: Schema.number().step(1).min(1), + // The adapter declares its catalog as a schema default rather than a + // composition entry, which is what the restore-defaults path has to read. + })).default([ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '', + contextWindow: 1_000_000, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '', + contextWindow: 1_000_000, + }, + ]), }) +const DEFAULT_DEEPSEEK_MODELS = [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Preserved hidden detail', + contextWindow: 1_000_000, + }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, +] + function wireNamespaces(): SettingsNamespaceView[] { return [ { ns: 'llm-deepseek', schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, - value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' }, - base: {}, + value: { + apiKeyEnv: 'DEEPSEEK_API_KEY', + baseURL: 'https://base', + reasoningEffort: 'high', + defaultContextWindow: 1_000_000, + maxTokens: 256_000, + models: DEFAULT_DEEPSEEK_MODELS, + }, + base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], @@ -104,7 +156,7 @@ function scriptedFace(overrides: { models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), }, settings: { - describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))), + describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))), update, replace, mutate, @@ -155,7 +207,7 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() - expect(screen.getByText(`+ ${en.add}`)).toBeTruthy() + expect(screen.getByText(en.add)).toBeTruthy() }) it('turns the setup card into a row once the credential reports configured', async () => { @@ -244,6 +296,388 @@ describe('ModelsSection', () => { }) }) + it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) + + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + expandRow(3) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) + fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) + // Only row 3 is open, so its capacity is addressed by its own label. + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + ...DEFAULT_DEEPSEEK_MODELS, + { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 }, + ], + }], + expectedRevision: 0, + }) + }) + + it('rejects duplicate DeepSeek model ids before writing', async () => { + const { mutate } = await mountSection() + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } }) + fireEvent.click(screen.getByText(en.apply)) + + await screen.findByText(`Model 3: ${en.modelIdDuplicate}`) + expect(mutate).not.toHaveBeenCalled() + }) + + it('validates every adapter-owned model catalog invariant', () => { + expect(modelDrafts(undefined)).toEqual([]) + expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }]) + expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + expect(validateDeepSeekModels([{ id: 'model', name: '' }])) + .toEqual({ index: 0, key: 'modelNameInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined() + expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined() + }) + + it('reads context windows written as counts, thousands, or millions', () => { + expect(parseCapacity('')).toBeUndefined() + expect(parseCapacity(' ')).toBeUndefined() + expect(parseCapacity('131072')).toBe(131_072) + expect(parseCapacity(' 256K ')).toBe(256_000) + expect(parseCapacity('256k')).toBe(256_000) + expect(parseCapacity('1M')).toBe(1_000_000) + expect(parseCapacity('1m')).toBe(1_000_000) + // 1M is 1000K, not 1024K: capacities are quoted in decimal. + expect(parseCapacity('1M')).toBe(parseCapacity('1000K')) + // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral + // intent must not become a fractional count the validator rejects. + expect(parseCapacity('2.3M')).toBe(2_300_000) + expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true) + // A genuinely fractional count survives as one, for the validator to reject. + expect(parseCapacity('0.0001K')).toBeCloseTo(0.1) + expect(parseCapacity('abc')).toBeNaN() + expect(parseCapacity('1G')).toBeNaN() + expect(parseCapacity('1M1')).toBeNaN() + }) + + it('spells a stored count in the shortest form that round-trips', () => { + expect(formatCapacity(1_000_000)).toBe('1M') + expect(formatCapacity(256_000)).toBe('256K') + expect(formatCapacity(1_500_000)).toBe('1500K') + expect(formatCapacity(131_072)).toBe('131072') + // Values the validator will reject are shown as-is rather than dressed up. + expect(formatCapacity(Number.NaN)).toBe('NaN') + expect(formatCapacity(0)).toBe('0') + for (const text of ['1M', '256K', '131072', '1500K']) { + expect(formatCapacity(parseCapacity(text) as number)).toBe(text) + } + }) + + it('accepts a suffixed context window and stores the plain count', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) + // The inherited 1000000 reads back short. + expect((windows[0] as HTMLInputElement).value).toBe('1M') + + // Keystrokes stay verbatim while the row has focus, so typing `1000` does + // not rewrite itself to `1K` mid-word. + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } }) + expect((windows[0] as HTMLInputElement).value).toBe('1000') + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } }) + expect((windows[0] as HTMLInputElement).value).toBe('1000K') + // Blur settles the row to the canonical spelling of the same count. + fireEvent.blur(windows[0] as HTMLInputElement) + expect((windows[0] as HTMLInputElement).value).toBe('1M') + + fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } }) + fireEvent.blur(windows[1] as HTMLInputElement) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + { ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 }, + { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 }, + ], + }], + expectedRevision: 0, + }) + }) + + it('keeps unreadable context-window text on screen and refuses the write', async () => { + const { mutate } = await mountSection() + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } }) + // Blurring a row that is not the edited one leaves the buffer alone. + fireEvent.blur(windows[1] as HTMLInputElement) + fireEvent.blur(windows[0] as HTMLInputElement) + // The text the user typed is still there to correct. + expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion') + + fireEvent.click(screen.getByText(en.apply)) + await screen.findByText(`Model 1: ${en.modelContextInvalid}`) + expect(mutate).not.toHaveBeenCalled() + }) + + it.each([ + ['the schema default', undefined], + ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }], + ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => { + // The regression: reset read the EFFECTIVE value, which still carries the + // stored override until the unset is applied — so the rows did not change + // and the catalog only looked restored after reopening the card. + const { face } = scriptedFace() + const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } + const overridden: SettingsNamespaceView = { + ns: 'llm-deepseek', + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + value: { ...stored, defaultContextWindow: 1_000_000 }, + ...base === undefined ? {} : { base }, + user: stored, + applies: 'live', + secrets: [], + revision: 0, + } + const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx') + render(<ProviderEditor + provider="deepseek-official" + displayName="DeepSeek" + namespace={overridden} + settingsPath={[]} + api={face as never} + t={t} + readOnly={false} + onClose={() => {}} + />) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsCustomized)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['user-only-model']) + + fireEvent.click(screen.getByText(en.resetModels)) + + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment']) + }) + + it('keeps every row\'s unreadable text, not just the last one edited', async () => { + // The regression: one active buffer meant editing a second row displaced + // the first, which then fell back to rendering its stored NaN as `NaN` — + // losing the text the user was told they could still correct. + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } }) + + expect((windows[0] as HTMLInputElement).value).toBe('not a number') + expect((windows[1] as HTMLInputElement).value).toBe('2M') + }) + + it('re-keys the typed text around a removed row', async () => { + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) + const removeRow = (at: number): void => { + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement) + } + // Three rows, with text parked on the outer two. + fireEvent.click(screen.getByText(en.addModel)) + expandRow(1) + expandRow(2) + expandRow(3) + fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } }) + fireEvent.blur(windows()[0] as HTMLInputElement) + fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } }) + fireEvent.blur(windows()[2] as HTMLInputElement) + + // Dropping the middle row leaves the row above untouched and carries the + // row below down with its own text, rather than stranding it. + removeRow(1) + expect(windows()).toHaveLength(2) + expect((windows()[0] as HTMLInputElement).value).toBe('top text') + expect((windows()[1] as HTMLInputElement).value).toBe('bottom text') + + // Dropping a row that holds text takes that text with it; the survivor + // keeps its own rather than inheriting the deleted row's. + removeRow(0) + expect(windows()).toHaveLength(1) + expect((windows()[0] as HTMLInputElement).value).toBe('bottom text') + }) + + it('drops the typed text when reset replaces the rows it annotated', async () => { + // The regression: reset removed the override but left the buffer, so an + // inherited row displayed text no settings layer stores — and because an + // unreadable buffer never settles, it stayed there indefinitely. + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.click(screen.getByText(en.resetModels)) + + // Reset collapses every row, so the restored capacity needs opening again. + expandRow(1) + const restored = capacityInputs(en.contextWindow) + expect((restored[0] as HTMLInputElement).value).toBe('1M') + + // Reset put the draft back where it started, so Apply writes nothing at + // all rather than persisting whatever the stale text had parsed to. + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() }) + expect(mutate).not.toHaveBeenCalled() + }) + + it('edits an output cap per model and carries its text across a removal', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + // The profile's own cap is the placeholder both rows inherit. + expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K']) + + fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } }) + fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`)) + expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K') + + // Dropping the row above carries the cap text down with its own row. + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K') + // The disclosure closes on a second press. + expandRow(1) + expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull() + + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }], + }], + expectedRevision: 0, + }) + }) + + it('settles a pasted id and refuses whitespace that would never match', async () => { + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId)) + fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } }) + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + // A settled id needs no second trim. + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + + // An id that is only whitespace is as absent as an empty one, and a padded + // id no longer slips past the duplicate check against its own twin. + expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + }) + + it('renders malformed draft fallbacks without inventing catalog values', () => { + render(<DeepSeekModelsEditor + models={[{}]} + overridden={false} + defaultContextWindow={undefined} + defaultMaxTokens={undefined} + t={t} + disabled={true} + onChange={vi.fn()} + onReset={vi.fn()} + />) + expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('') + expandRow(1) + expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder) + .toBe(en.contextWindowPlaceholder) + expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder) + .toBe(en.maxTokensPlaceholder) + }) + + it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel))) + expect(screen.getByText(en.modelsEmpty)).toBeTruthy() + fireEvent.click(screen.getByText(en.resetModels)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) + fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' }, + DEFAULT_DEEPSEEK_MODELS[1], + ], + }], + expectedRevision: 0, + }) + }) + it('clears an inherited override with an unset op, never a whole-section replace', async () => { // The data-loss shape: the old path rebuilt the section from the REDACTED // user layer and replaced it wholesale, deleting any stored literal key. @@ -332,7 +766,7 @@ describe('ModelsSection', () => { it('adds a dormant provider with a derived reference and stores its key', async () => { const { mutate, set } = await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') @@ -356,7 +790,7 @@ describe('ModelsSection', () => { it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider) fireEvent.change(pick, { target: { value: 'broken' } }) await screen.findByText(/unresolvable settings path/) @@ -374,7 +808,7 @@ describe('ModelsSection', () => { const { set } = await mountSection({ mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))), }) - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput) fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) @@ -541,6 +975,7 @@ describe('ModelsSection', () => { const { face } = await mountSection() face.settings.describe.mockImplementation(() => Promise.resolve(ok({ writable: false, + hasDocument: false, namespaces: wireNamespaces(), }))) const controller = new ModelsSettingsStore(face as unknown as WireFace) @@ -554,7 +989,7 @@ describe('ModelsSection', () => { />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true) - expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true) + expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true) }) it('toggles the row editor closed on a second edit click and on cancel', async () => { @@ -573,10 +1008,10 @@ describe('ModelsSection', () => { it('cancels the add card back to the add button', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - await screen.findByText(`+ ${en.add}`) + await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index ca7d703c93..ee9aa2ddaf 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -51,7 +51,7 @@ function api(overrides: { models: () => Promise.resolve(ok({ groups: [], failures: [] })), }, settings: { - describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))), + describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))), update: () => Promise.resolve(fail('unused')), replace: () => Promise.resolve(fail('unused')), }, @@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => { const { face } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [{ ...NAMESPACES[0], secrets: [ @@ -195,6 +196,7 @@ describe('edge joins', () => { const { face } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [{ ns: 'llm-pi-ai', schema: {}, @@ -221,6 +223,7 @@ describe('edge joins', () => { const { face, seenRefs } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never, })), providers: () => Promise.resolve(ok({ diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 478046454b..879d9812a2 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -3,11 +3,36 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') +const tokens = readFileSync( + fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)), + 'utf8', +) + +/** The declarations of one top-level rule, by selector. */ +function block(selector: string): string { + const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css) + if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`) + return match[1] ?? '' +} describe('ModelsSection theme styles', () => { - it('uses the shared theme tokens without light-only fallbacks', () => { + it('names only theme variables the token sheet defines', () => { + // A `--dsw-*` name the sheet never declares is not a near miss: it silently + // resolves to whatever literal sits in its fallback slot, which is how this + // section stayed light under the dark theme before. Undeclared names have + // no fallback at all and inherit, so both spellings must fail here. + const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1]) + const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`)) + expect(undeclared).toEqual([]) expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) - expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') - expect(css).toContain('color: var(--dsw-alias-label-primary)') + }) + + it('separates the row card from the editor it expands into', () => { + // `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800 + // under the dark theme, so filling the row with either erases the nested + // editor's boundary. The row is outlined; the fill is the editor's alone. + expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)') + expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)') + expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) }) }) diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml index 736b325754..2f19a8f962 100644 --- a/packages/client/ui-permission/README.i18n.yaml +++ b/packages/client/ui-permission/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4 -README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59 +README.zh.md: 70bbbb2d14358cbe52a6fc27deb7ce01d5f3679b diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md index e4b39567e4..70bbbb2d14 100644 --- a/packages/client/ui-permission/README.zh.md +++ b/packages/client/ui-permission/README.zh.md @@ -6,16 +6,16 @@ 当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。 -`/client` 导出面为插件本体(`apply`/`inject`)。 +`/client` 导出面为插件本体(`apply`/`inject`)。 -## Model Experience +## 模型体验 通过两个界面写入的权限事实间接影响:Settings 行使未来会话带着全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`)启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略,选择框交互本身不添加任何提示词内容。 -#### KV Cache effect +#### KV Cache 影响 无直接失效;请求前缀的变化由旋钮消费方自行承担。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。 diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 33813d5af1..55d896dbec 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index d6cd286959..66b15ed115 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -19,7 +19,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' import { PermissionRow } from './PermissionRow.tsx' import type { PermissionRowInjected } from './PermissionRow.tsx' @@ -133,17 +132,13 @@ export function apply(ctx: ClientContext): void { } }, 'ui-permission: settings invalidations') - ctx.effect(() => { - const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'permission', - order: -20, - locale: 'settings.permission', - inject: injected, - }, PermissionRow)) - return () => { row.dispose() } - }, 'ui-permission: General settings row') + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'permission', + order: -20, + locale: 'settings.permission', + inject: injected, + }, PermissionRow)) ctx.effect(() => command.decorate({ name: 'permission', diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index fea56a413a..309298a64a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -48,7 +48,7 @@ async function bench() { settings: { describe: () => Promise.resolve({ rpcId: 'describe', - result: { ok: true as const, value: { writable: true, namespaces: [] } }, + result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }, }), mutate: () => Promise.reject(new Error('settings mutation is not exercised')), }, diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx index f74e6ae2ad..cb6c69ac09 100644 --- a/packages/client/ui-permission/tests/permission-row.spec.tsx +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -60,7 +60,7 @@ describe('PermissionRow', () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) const controller = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, } as never, }) @@ -87,7 +87,7 @@ describe('PermissionRow', () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) const controller = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, } as never, }) @@ -111,7 +111,7 @@ describe('PermissionRow', () => { it('hides an unavailable namespace and disables a read-only provider', async () => { const absent = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })), + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), } as never, }) @@ -121,7 +121,7 @@ describe('PermissionRow', () => { const readonly = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })), + describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), } as never, }) @@ -148,7 +148,7 @@ describe('PermissionRow', () => { }) mount(controller) expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) - describe.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) const button = await screen.findByRole('button', { name: 'Read Only' }) fireEvent.click(button) fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts index 74edb838b0..8ee09914e3 100644 --- a/packages/client/ui-permission/tests/settings-store.spec.ts +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -88,6 +88,7 @@ describe('permission settings store', () => { it('loads and writes defaultPreset with optimistic concurrency', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) @@ -115,7 +116,7 @@ describe('permission settings store', () => { }) it('hides the row when the namespace is absent and contains write failures', async () => { - const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] }))) + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) const controller = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never, }) @@ -124,7 +125,7 @@ describe('permission settings store', () => { const failing = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ rpcId: 'test', result: { @@ -146,14 +147,14 @@ describe('permission settings store', () => { }>>>() const describe = vi.fn() .mockImplementationOnce(() => first.promise) - .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] })) + .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) const mutate = vi.fn() const controller = new PermissionSettingsController({ settings: { describe, mutate } as never, }) const stale = controller.load() await controller.load() - first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] })) + first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] })) await stale expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'read-only', @@ -200,7 +201,7 @@ describe('permission settings store', () => { expect(describe).not.toHaveBeenCalled() const loading = idle.load() idle.dispose() - read.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) await loading expect(idle.store.getSnapshot().status).toBe('loading') @@ -220,6 +221,7 @@ describe('permission settings store', () => { const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>() const activeDescribe = vi.fn(() => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [view('read-only')], }))) const active = new PermissionSettingsController({ @@ -240,7 +242,7 @@ describe('permission settings store', () => { const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>() const disposedWrite = new PermissionSettingsController({ settings: { - describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => rejectedMutation.promise, } as never, }) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 772d65f86d..86640d128b 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md README.md: fcc4fbab4fbe1a8cc27119366b21ef55c669ba30 -README.zh.md: b618199616e45f69d62f3507c96d367bb3b9909f +README.zh.md: f512d40568058ef061c6f262eadb20acda78cb64 diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index b618199616..f512d40568 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 +Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占用会话声明的 `conversation.input.plan` 单实例 seat(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 +plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 @@ -14,12 +14,12 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m 间接地,通过 chip 派发的 `/plan off` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有该命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。 -#### KV 缓存效应 +#### KV Cache 影响 进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段,因此改变请求前缀;chip 本身不添加任何提示词内容。 ## 已知局限与延后工作 -- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 -- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 +- **Plan mode 是引导而非执行沙箱**:需要强制只读规划的部署必须组合独立的沙箱与审批策略。 +- **chip 属于默认编辑器**:待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 - **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f80a2be1ae..2ded06429c 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -66,8 +66,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 64e87ae951..746c34a4e8 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -39,12 +39,8 @@ export interface PlanChipInjected { exitPlanMode: () => Promise<string | null> } -/** - * Required services: the seat's slot registry, the transport, the copy's - * locale registry, and the conversation service whose presence guarantees - * the seat is declared. - */ -export const inject = ['slots', 'connection', 'conversation', 'locale'] +/** Required services: the seat's slot registry, transport, and locale registry. */ +export const inject = ['slots', 'connection', 'locale'] /** * Client plugin body: register the plan chip over the command channel. @@ -53,7 +49,7 @@ export const inject = ['slots', 'connection', 'conversation', 'locale'] export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries') - ctx.effect(() => ctx.slots.register({ + ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({ name: 'conversation.input.plan', locale: NS, inject: (sessionId: SessionId): PlanChipInjected => ({ @@ -66,5 +62,5 @@ export function apply(ctx: ClientContext): void { return null }, }), - }, PlanChip), 'ui-plan: composer plan chip registration') + }, PlanChip)) } diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 4f028724ea..e384ba5356 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -28,28 +28,32 @@ async function bench() { const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) ctx.provide('connection', { api: { commands: { execute } } }) - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots, execute } } describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'connection', 'conversation', 'locale']) + expect(inject).toEqual(['slots', 'connection', 'locale']) }) it('node-half apply is an intentional no-op', () => { expect(() => { nodeApply() }).not.toThrow() }) - it('fails loud when conversation did not declare the plan seat', async () => { + it('waits until conversation declares the plan seat', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() ctx.provide('connection', {}) - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) - await expect(ctx.plugin({ inject: [...inject], apply })) - .rejects.toThrow(/slot "conversation.input.plan" is not declared/) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(0) + ctx.slots.register({ + name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, + } as never, () => null) + await Promise.resolve() + expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(1) }) it('registers the chip, executes /plan off, and unregisters on teardown', async () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8c597efd5a..6de113572f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 00e9560f43c83e1edc61c185a4fc562c6c923e8b -README.zh.md: 21226ab211106b7722139828762605cb71a4b498 +README.md: c64e86152737fd55c49ac39cc2e7b523e0323774 +README.zh.md: 29123c3570122bc0fe6a1808a75c5bc659315eaa diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 00e9560f43..c64e861527 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,11 +10,11 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering @@ -22,7 +22,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Diff rendering -`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). +`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). ## Search results @@ -44,6 +44,6 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. -- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. +- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error. - **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 21226ab211..29123c3570 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,11 +10,11 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## Read 渲染 @@ -22,7 +22,7 @@ ## Diff 渲染 -`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 +`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 ## 搜索结果 @@ -44,6 +44,6 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 -- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 +- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 - **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 36962c90cb..8bfbed69ca 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -26,7 +26,14 @@ "katex": "^0.16.47", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", + "micromark-core-commonmark": "^2.0.3", "micromark-extension-gfm": "^3.0.0", + "micromark-extension-math": "^3.1.0", + "micromark-factory-space": "^2.0.1", + "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", @@ -44,9 +51,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 77f9c0a794..e83851edfc 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './StateDot.module.css' -/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ +/** Four-color state semantic (green done / amber user-attention / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' /** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 4da0eebc2d..56b93f5be1 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -1,6 +1,6 @@ /* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow), - except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling: - tooltip-bg plate, + except padding tightened 6/12 -> 3/7, type 14/22 -> 13/20, and radius + 10 -> 8 by product ruling: tooltip-bg plate, one text color across both themes (the plate stays dark in light and dark mode). Behavior (fixed positioning off the anchor rect) is local — the upstream Floating stack is intentionally not vendored. */ @@ -8,13 +8,20 @@ .bubble { position: fixed; z-index: 100; - padding: 4px 8px; + /* Fixed-position shrink-to-fit measures only the space from `left` to the + viewport edge, so anchors near the right edge would wrap early; + max-content sizes by the label alone, capped at half the viewport. */ + width: max-content; + max-width: 50vw; + padding: 3px 7px; border-radius: 8px; background: var(--dsw-alias-tooltip-bg); color: var(--dsw-static-neutral-bluish-00); - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; white-space: pre-line; + /* Unbreakable tokens (URLs, paths) must not push past max-width. */ + overflow-wrap: break-word; pointer-events: none; animation: tooltip-in 150ms var(--ds-ease-in-out); } @@ -27,6 +34,10 @@ transform: translateX(-50%); } +.bubble[data-side='top'] { + transform: translate(-50%, -100%); +} + @keyframes tooltip-in { from { opacity: 0; } } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index e85583a50c..449d4fe717 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,18 +1,19 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (no flip on viewport collision or -// arrow) — visuals and behavior get a proper pass later. +// TODO: interaction is a placeholder (horizontal overflow clamps, but there +// is no vertical flip on viewport collision and no arrow) — visuals and +// behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so // it escapes ancestor overflow clipping (the sidebar rail clips its column) // without a portal. -import { cloneElement, useCallback, useEffect, useRef, useState } from 'react' +import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ -export type TooltipSide = 'right' | 'bottom' +export type TooltipSide = 'right' | 'bottom' | 'top' /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */ interface AnchorProps { @@ -44,6 +45,29 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const bubble = useRef<HTMLSpanElement | null>(null) + // Horizontal viewport clamp: fixed positioning knows nothing about edges, so + // a centered bubble near the right edge would clip. Each measurement resets + // the base position before applying a direct style offset, allowing a shorter + // label or wider viewport to release a previous clamp without another render. + useLayoutEffect(() => { + if (pos === null) return + const clamp = () => { + const el = bubble.current + /* v8 ignore next -- pos is set only while the bubble is mounted. */ + if (el === null) return + const EDGE_MARGIN = 12 + el.style.left = `${pos.x}px` + const r = el.getBoundingClientRect() + let dx = 0 + if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right + if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left + el.style.left = `${pos.x + dx}px` + } + clamp() + window.addEventListener('resize', clamp) + return () => { window.removeEventListener('resize', clamp) } + }, [label, pos]) const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -73,7 +97,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, const r = el.getBoundingClientRect() setPos(side === 'right' ? { x: r.right + 10, y: r.top + r.height / 2 } - : { x: r.left + r.width / 2, y: r.bottom + 8 }) + : side === 'top' + ? { x: r.left + r.width / 2, y: r.top - 8 } + : { x: r.left + r.width / 2, y: r.bottom + 8 }) } const showAfterHoverDelay = () => { cancelShow() @@ -101,7 +127,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( - <span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip"> + <span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip"> {label} </span> )} diff --git a/packages/client/ui-primitives/src/clipboard.ts b/packages/client/ui-primitives/src/clipboard.ts index 39a0eb3e76..0ef84a4123 100644 --- a/packages/client/ui-primitives/src/clipboard.ts +++ b/packages/client/ui-primitives/src/clipboard.ts @@ -1,6 +1,5 @@ -// Package-internal clipboard write, shared by every copy control in this -// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the -// public surface: consumers get the components, not the host detection. +// Host clipboard write shared by Web UI copy controls. Success feedback stays +// with each control; this seam only reports whether the host accepted a write. /** * Write text to the host clipboard, preferring the async Clipboard API and diff --git a/packages/client/ui-primitives/src/head-tail-cap.ts b/packages/client/ui-primitives/src/head-tail-cap.ts index 1ac540dd21..f0852052a4 100644 --- a/packages/client/ui-primitives/src/head-tail-cap.ts +++ b/packages/client/ui-primitives/src/head-tail-cap.ts @@ -1,6 +1,5 @@ // Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock, -// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long -// result's head and tail slices agree across every surface. The split is +// SearchBlock), so long results use consistent head and tail slices. The split is // `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within // the cap shows every row and hides none. diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 74e3e757b2..71e647a3e9 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -675,6 +675,26 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( </svg> ) +/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */ +export const IconSendOutline14 = ({ size = 14, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.24707 1.01771C7.52897 1.07653 7.77619 1.19694 8.00391 1.38001C8.19202 1.53136 8.39884 1.73784 8.61914 1.95814L12.6396 5.9806L11.6299 6.99134L7.71484 3.0763V13.0001H6.28516V3.0763L2.36914 6.99134L1.35938 5.9806L5.38086 1.95814C5.60116 1.73784 5.80798 1.53136 5.99609 1.38001C6.19476 1.22027 6.4385 1.06739 6.75195 1.01771C6.91296 0.992304 7.07471 0.997504 7.24707 1.01771Z" + fill="currentColor" + /> + </svg> +) + +/** ic_queue_outline_14 (figma extract): open chat bubble with two queued lines. */ +export const IconQueueOutline14 = ({ size = 14, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.00049 0.199829C3.24488 0.199829 0.199952 3.24408 0.199707 6.99963C0.199707 8.0414 0.434087 9.03061 0.854004 9.91467L1.11279 10.4576L2.19775 9.94202L1.94092 9.39905L1.81787 9.12268C1.5498 8.46885 1.40186 7.75171 1.40186 6.99963C1.4021 3.90808 3.90888 1.40198 7.00049 1.40198C10.0919 1.40219 12.5979 3.90821 12.5981 6.99963C12.5981 10.0913 10.0921 12.5981 7.00049 12.5983C6.36734 12.5983 5.90348 12.5535 5.49268 12.4401C5.08803 12.3283 4.7041 12.1414 4.24463 11.8209C3.57111 11.3511 2.60588 11.1855 1.81006 11.6881L1.79736 11.6959L1.78467 11.7047L1.25537 12.0778L1.65381 13.2672L2.46045 12.6989C2.75029 12.5214 3.18004 12.5442 3.55615 12.8063C4.10063 13.1861 4.60863 13.4423 5.17334 13.5983C5.73194 13.7525 6.31665 13.8004 7.00049 13.8004C10.7561 13.8002 13.8003 10.7553 13.8003 6.99963C13.8 3.24421 10.7559 0.200041 7.00049 0.199829ZM3.81201 7.47327V8.67542H7.11572V7.47327H3.81201ZM3.81201 6.34924H10.2173V5.14709H3.81201V6.34924Z" + fill="currentColor" + /> + </svg> +) + /** ic_checklist_outline_14 (figma extract): two rings + two list bars. */ export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> @@ -703,7 +723,23 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( </svg> ) -/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star +/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */ +export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M8 0C8.31451 0 8.62464 0.019379 8.92969 0.0546875C8.48228 0.403371 8.0952 0.825758 7.78809 1.30469C4.18586 1.41664 1.2998 4.37061 1.2998 8C1.2998 11.7003 4.29969 14.7002 8 14.7002C11.6297 14.7002 14.5829 11.8136 14.6943 8.21094C15.1734 7.90377 15.5956 7.51688 15.9443 7.06934C15.9797 7.37473 16 7.68512 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM7.0166 3.6084C7.00658 3.73765 7 3.86817 7 4C7 4.31845 7.03098 4.62973 7.08789 4.93164C5.76489 5.32438 4.7998 6.54958 4.7998 8C4.7998 9.76731 6.23269 11.2002 8 11.2002C9.45065 11.2002 10.6749 10.2345 11.0674 8.91113C11.3696 8.96818 11.6812 9 12 9C12.1315 9 12.2617 8.99239 12.3906 8.98242C11.9423 10.995 10.1477 12.5 8 12.5C5.51472 12.5 3.5 10.4853 3.5 8C3.5 5.85255 5.00435 4.05702 7.0166 3.6084Z" + fill="currentColor" + /> + <path d="M7.5 8.62109L9.12109 7" stroke="currentColor" strokeWidth="1.3" /> + <path + d="M9.08245 3.35798L11.8651 0.575334C11.895 0.545384 11.9463 0.56391 11.9502 0.606086L12.2362 3.69859C12.2384 3.72259 12.2574 3.74159 12.2814 3.74378L15.3697 4.02583C15.4119 4.02968 15.4305 4.08101 15.4005 4.11098L12.618 6.89351C12.6086 6.90289 12.5959 6.90816 12.5826 6.90816L9.11781 6.90815C9.09019 6.90816 9.06781 6.88577 9.06781 6.85816L9.06781 3.39333C9.06781 3.38007 9.07308 3.36735 9.08245 3.35798Z" + stroke="currentColor" + strokeWidth="1.3" + /> + </svg> +) + +/** sparkle_16 (Others tool-row leading glyph; hand-authored three-star * approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph, * not extractable as vector data) */ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 79bae60c36..feecb95d3e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -20,6 +20,7 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { writeClipboard } from './clipboard.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 72e9168661..b09f1f46fe 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,15 +1,22 @@ -import { isValidElement, useMemo } from 'react' +import { isValidElement, useMemo, type ReactNode } from 'react' import ReactMarkdown from 'react-markdown' import type { Components, UrlTransform } from 'react-markdown' import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { CodeBlock } from './CodeBlock.tsx' +import { remarkCjkFriendlyStrong } from './remarkCjkFriendlyStrong.ts' +import { remarkMathCompatibility } from './remarkMathCompatibility.ts' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -const streamingRemarkPlugins = [remarkGfm] -const settledRemarkPlugins = [remarkGfm, remarkMath] +const streamingRemarkPlugins = [remarkGfm, remarkCjkFriendlyStrong] +const settledRemarkPlugins = [ + remarkGfm, + remarkCjkFriendlyStrong, + remarkMathCompatibility, + remarkMath, +] const settledRehypePlugins = [rehypeKatex] function sanitizeUrl(url: string): string { @@ -29,6 +36,30 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) +function renderSafeLink(href: string, children: ReactNode): ReactNode { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children}</> + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + <a + href={safeHref} + {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})} + > + {children} + </a> + ) +} + +function inlineCodeHttpUrl(value: string): string | undefined { + if (value.trim() !== value) return undefined + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ export interface MarkdownCodeLabels { /** Copy-button idle label. */ @@ -49,18 +80,10 @@ function remoteImageUrl(url: string): string | undefined { /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components { return { - a: ({ href = '', children }) => { - const safeHref = sanitizeUrl(href) - if (safeHref === '') return <>{children}</> - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) - return ( - <a - href={safeHref} - {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})} - > - {children} - </a> - ) + a: ({ href = '', children }) => renderSafeLink(href, children), + code: ({ className, children }) => { + const href = typeof children === 'string' ? inlineCodeHttpUrl(children) : undefined + return <code className={className}>{href === undefined ? children : renderSafeLink(href, children)}</code> }, img: ({ alt = '', src = '' }) => { const imageSrc = remoteImageUrl(src) @@ -83,10 +106,11 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C ), // Fenced blocks route through the shared CodeBlock (shiki for registered // grammars, identical-geometry plain fallback for unknown/absent - // languages); inline code keeps the default <code> path (the :not(pre) - // rule styles it). While the message streams, the fence renders the - // plain arm — retokenizing a growing fence on every chunk is quadratic - // main-thread work; the finalize swap highlights it once. + // languages); inline code keeps the <code> path (the :not(pre) rule + // styles it), with a safe anchor only for complete HTTP(S) values. While + // the message streams, the fence renders the plain arm — retokenizing a + // growing fence on every chunk is quadratic main-thread work; the + // finalize swap highlights it once. pre: ({ children }) => { // The markdown pipeline always hands `pre` its single `code` element; // the undefined arm guards a react-markdown representation change. @@ -121,7 +145,8 @@ const streamingComponents = buildComponents(true) * component table memoizes on its identity and a fresh literal per render * would rebuild it every streaming chunk. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, - * relative links, and unsafe protocols are disabled, while absolute HTTP(S) + * relative links, and unsafe protocols are disabled; complete HTTP(S) + * inline-code values become safe external links, while absolute HTTP(S) * images render directly. */ export function MarkdownText({ text, streaming = false, codeLabels }: { diff --git a/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts new file mode 100644 index 0000000000..a185483723 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts @@ -0,0 +1,88 @@ +/** Let asterisk strong emphasis close after punctuation when CJK prose continues without whitespace. */ + +import { attention } from 'micromark-core-commonmark' +import { unicodePunctuation } from 'micromark-util-character' +import { classifyCharacter } from 'micromark-util-classify-character' +import { codes, constants } from 'micromark-util-symbol' +import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types' + +interface RemarkProcessor { + data(): { micromarkExtensions?: Extension[] } +} + +const cjkCharacter = new RegExp([ + '\\p{Script_Extensions=Han}', + '\\p{Script_Extensions=Hiragana}', + '\\p{Script_Extensions=Katakana}', + '\\p{Script_Extensions=Hangul}', + '\\p{Script_Extensions=Bopomofo}', +].join('|'), 'u') + +function isCjkCharacter(code: number | null): boolean { + return code !== null && code >= 0 && cjkCharacter.test(String.fromCodePoint(code)) +} + +const tokenizeCjkFriendlyAttention: Tokenizer = function (effects, ok, nok) { + const configuredAttentionMarkers = this.parser.constructs.attentionMarkers.null + if (configuredAttentionMarkers === undefined) { + throw new Error('micromark CommonMark attention markers are unavailable') + } + const attentionMarkers = configuredAttentionMarkers + const previous = this.previous + const before = classifyCharacter(previous) + let marker: number | null = codes.eof + + return start + + function start(code: number | null): State | undefined { + /* v8 ignore next -- this text construct is dispatched only for an asterisk. */ + if (code !== codes.asterisk) return nok(code) + marker = code + effects.enter('attentionSequence') + return inside(code) + } + + function inside(code: number | null): State | undefined { + if (code === marker) { + effects.consume(code) + return inside + } + + const token = effects.exit('attentionSequence') + const after = classifyCharacter(code) + const open = !after || (after === constants.characterGroupPunctuation && Boolean(before)) + || attentionMarkers.includes(code) + const commonMarkClose = !before + || (before === constants.characterGroupPunctuation && Boolean(after)) + || attentionMarkers.includes(previous) + const markerCount = token.end.offset - token.start.offset + const cjkStrongClose = markerCount >= 2 + && unicodePunctuation(previous) + && isCjkCharacter(code) + const close = commonMarkClose || cjkStrongClose + + token._open = open + token._close = close + return ok(code) + } +} + +const cjkFriendlyAttention: Construct = { + name: 'cjkFriendlyAttention', + resolveAll: attention.resolveAll, + tokenize: tokenizeCjkFriendlyAttention, +} + +const cjkFriendlyStrong: Extension = { + text: { [codes.asterisk]: cjkFriendlyAttention }, +} + +/** + * Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK prose. + * @returns Nothing. + */ +export function remarkCjkFriendlyStrong(this: RemarkProcessor): undefined { + const data = this.data() + const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) + extensions.push(cjkFriendlyStrong) +} diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts new file mode 100644 index 0000000000..dcd8c32362 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -0,0 +1,353 @@ +/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */ + +import { factorySpace } from 'micromark-factory-space' +import type {} from 'micromark-extension-math' +import { markdownLineEnding } from 'micromark-util-character' +import { codes, constants, types } from 'micromark-util-symbol' +import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types' + +// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback. + +interface RemarkProcessor { + data(): { micromarkExtensions?: Extension[] } +} + +const previousBackslash: Previous = function (code) { + if (code !== codes.backslash) return true + const tail = this.events.at(-1) + /* v8 ignore next -- a previous code necessarily has a preceding event. */ + if (tail === undefined) return false + return tail[1].type === types.characterEscape +} + +const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { + return start + + function start(code: number | null): State | undefined { + /* v8 ignore next -- the text construct is dispatched only for a backslash. */ + if (code !== codes.backslash) return nok(code) + effects.enter('mathText') + effects.enter('mathTextSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== codes.leftParenthesis) return nok(code) + effects.consume(code) + effects.exit('mathTextSequence') + return between + } + + function between(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === codes.backslash) { + return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code) + } + if (markdownLineEnding(code)) { + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return between + } + return dataStart(code) + } + + function afterCloseAttempt(code: number | null): State | undefined { + return effects.check({ partial: true, tokenize: tokenizeOpen }, nok, dataStart)(code) + } + + function dataStart(code: number | null): State | undefined { + effects.enter('mathTextData') + effects.consume(code) + return code === codes.backslash ? afterDataBackslash : data + } + + function afterDataBackslash(code: number | null): State | undefined { + if (code === codes.backslash) { + effects.consume(code) + return data + } + return data(code) + } + + function data(code: number | null): State | undefined { + if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) { + effects.exit('mathTextData') + return between(code) + } + effects.consume(code) + return data + } + + function close(code: number | null): State | undefined { + effects.exit('mathText') + return ok(code) + } + + function tokenizeClose(closeEffects: Parameters<Tokenizer>[0], closeOk: State, closeNok: State): State { + return slash + + function slash(code: number | null): State | undefined { + /* v8 ignore next -- this partial construct is attempted only at a backslash. */ + if (code !== codes.backslash) return closeNok(code) + closeEffects.enter('mathTextSequence') + closeEffects.consume(code) + return parenthesis + } + + function parenthesis(code: number | null): State | undefined { + if (code !== codes.rightParenthesis) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathTextSequence') + return closeOk + } + } + + function tokenizeOpen(openEffects: Parameters<Tokenizer>[0], openOk: State, openNok: State): State { + return slash + + function slash(code: number | null): State | undefined { + /* v8 ignore next -- the opening check follows a failed close attempt at a backslash. */ + if (code !== codes.backslash) return openNok(code) + openEffects.enter(types.chunkString) + openEffects.consume(code) + return parenthesis + } + + function parenthesis(code: number | null): State | undefined { + if (code !== codes.leftParenthesis) return openNok(code) + openEffects.consume(code) + openEffects.exit(types.chunkString) + return openOk + } + } +} + +function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { + const tokenize: Tokenizer = function (effects, ok, nok) { + const self = this + let oddBackslashRun = false + const tail = self.events.at(-1) + const initialSize = tail?.[1].type === types.linePrefix + ? tail[2].sliceSerialize(tail[1], true).length + : 0 + + return start + + function start(code: number | null): State | undefined { + /* v8 ignore next -- the flow construct is dispatched only for its marker. */ + if (code !== marker) return nok(code) + effects.enter('mathFlow') + effects.enter('mathFlowFence') + effects.enter('mathFlowFenceSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== openMarker) return nok(code) + effects.consume(code) + effects.exit('mathFlowFenceSequence') + effects.exit('mathFlowFence') + return marker === codes.dollarSign ? afterDollarOpen : content + } + + function afterDollarOpen(code: number | null): State | undefined { + return code === codes.dollarSign ? nok(code) : content(code) + } + + function content(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) { + return effects.attempt( + { partial: true, tokenize: tokenizeClosingFence }, + closed, + afterClosingFenceAttempt, + )(code) + } + if (markdownLineEnding(code)) { + return multiline + ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) + : nok(code) + } + return valueStart(code) + } + + function afterClosingFenceAttempt(code: number | null): State | undefined { + return marker === codes.backslash + ? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code) + : markerValueStart(code) + } + + function afterContinuation(code: number | null): State | undefined { + return effects.attempt( + { partial: true, tokenize: tokenizeClosingFence }, + closed, + initialSize + ? factorySpace(effects, content, types.linePrefix, initialSize + 1) + : content, + )(code) + } + + function valueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + oddBackslashRun = code === codes.backslash + effects.consume(code) + return value + } + + function markerValueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + oddBackslashRun = false + effects.consume(code) + return valueAfterMarker + } + + function valueAfterMarker(code: number | null): State | undefined { + if (code === marker) { + effects.consume(code) + return value + } + return value(code) + } + + function value(code: number | null): State | undefined { + if (code === codes.eof || code === marker || markdownLineEnding(code)) { + effects.exit('mathFlowValue') + return content(code) + } + oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false + effects.consume(code) + return value + } + + function closed(code: number | null): State | undefined { + effects.exit('mathFlow') + return ok(code) + } + + function tokenizeClosingFence( + closeEffects: Parameters<Tokenizer>[0], + closeOk: State, + closeNok: State, + ): State { + return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize) + + function sequenceStart(code: number | null): State | undefined { + if (code !== marker) return closeNok(code) + closeEffects.enter('mathFlowFence') + closeEffects.enter('mathFlowFenceSequence') + closeEffects.consume(code) + return sequenceEnd + } + + function sequenceEnd(code: number | null): State | undefined { + if (code !== closeMarker) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathFlowFenceSequence') + return factorySpace(closeEffects, after, types.whitespace) + } + + function after(code: number | null): State | undefined { + if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code) + closeEffects.exit('mathFlowFence') + return closeOk(code) + } + } + + function tokenizeOpeningFence( + openEffects: Parameters<Tokenizer>[0], + openOk: State, + openNok: State, + ): State { + return sequenceStart + + function sequenceStart(code: number | null): State | undefined { + /* v8 ignore next -- the opening check follows a failed close attempt at the marker. */ + if (code !== marker) return openNok(code) + openEffects.enter(types.chunkString) + openEffects.consume(code) + return sequenceEnd + } + + function sequenceEnd(code: number | null): State | undefined { + if (code !== openMarker) return openNok(code) + openEffects.consume(code) + openEffects.exit(types.chunkString) + return openOk + } + } + } + + return { + concrete: true, + name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow', + tokenize, + } +} + +const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) { + const self = this + + return start + + function start(code: number | null): State | undefined { + /* v8 ignore next -- continuation constructs are attempted only after a line ending. */ + if (code === codes.eof) return ok(code) + /* v8 ignore next -- continuation constructs are attempted only after a line ending. */ + if (!markdownLineEnding(code)) return nok(code) + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return lineStart + } + + function lineStart(code: number | null): State | undefined { + return self.parser.lazy[self.now().line] ? nok(code) : ok(code) + } +} + +const nonLazyContinuation: Construct = { + partial: true, + tokenize: tokenizeNonLazyContinuation, +} + +const backslashMathText: Construct = { + name: 'backslashMathText', + previous: previousBackslash, + tokenize: tokenizeBackslashMathText, +} + +const backslashMathFlow = createMathFlow( + codes.backslash, + codes.leftSquareBracket, + codes.rightSquareBracket, + true, +) + +const sameLineDollarMathFlow = createMathFlow( + codes.dollarSign, + codes.dollarSign, + codes.dollarSign, + false, +) + +const backslashMath: Extension = { + flow: { + [codes.backslash]: backslashMathFlow, + [codes.dollarSign]: sameLineDollarMathFlow, + }, + text: { [codes.backslash]: backslashMathText }, +} + +/** + * Add TeX backslash delimiters and same-line display-dollar blocks for remark-math. + * The same processor must register remark-math to compile the emitted math tokens. + * @returns Nothing. + */ +export function remarkMathCompatibility(this: RemarkProcessor): undefined { + const data = this.data() + const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) + extensions.push(backslashMath) +} diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index cc5175cba4..9877b7df1f 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -2,7 +2,9 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' -import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) @@ -14,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(61) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => { + expect(iconNames.length).toBe(64) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { @@ -44,6 +46,12 @@ describe('ic_ds_ icon set', () => { const archive = render(<IconArchiveOutline20 />) expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20') }) + + it('renders reusable goal glyphs without document-global ids', () => { + const { container } = render(<><IconGoalOutline16 /><IconGoalOutline16 /></>) + expect(container.querySelector('[id]')).toBeNull() + expect(container.querySelector('[clip-path]')).toBeNull() + }) }) describe('FishLogo', () => { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 10af104e5a..e67302a305 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,7 +1,10 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' +import type { Extension } from 'micromark-util-types' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts' +import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts' afterEach(cleanup) @@ -66,6 +69,104 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => { + const cases = [ + ['**注意:**内容', '注意:'], + ['**Notice:**内容', 'Notice:'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**句号。**后续', '句号。'], + ['**Period.**后续', 'Period.'], + ['**提醒!**继续', '提醒!'], + ['**Warning!**继续', 'Warning!'], + ] as const + const source = cases.map(([markdown]) => markdown).join('\n\n') + + for (const streaming of [false, true]) { + const rendered = render(<MarkdownText text={source} streaming={streaming} />) + expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent)) + .toEqual(cases.map(([, strong]) => strong)) + rendered.unmount() + } + }) + + it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => { + const source = [ + String.raw`\**注意:**内容`, + '`**注意:**内容`', + '**Notice:**text', + '*提醒!*继续', + '$**注意:**内容$', + '```md', + '**注意:**内容', + '```', + '**普通**内容', + '*普通*内容', + ].join('\n\n') + const { container } = render(<MarkdownText text={source} />) + + expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通']) + expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通']) + expect(container.querySelector('code')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容') + expect(container.textContent).toContain('**Notice:**text') + expect(container.textContent).toContain('*提醒!*继续') + expect(container.textContent).toContain('**注意:**内容') + }) + + it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => { + const localUrl = 'http://127.0.0.1:3199/?demo=1' + const remoteUrl = 'https://example.com/preview?q=one%20two#result' + const source = [ + `\`${localUrl}\``, + `\`${remoteUrl}\``, + '`curl http://127.0.0.1:3199/?demo=1`', + '`javascript:alert(1)`', + '`mailto:dev@example.com`', + `\` ${localUrl} \``, + '```', + localUrl, + '```', + ].join('\n\n') + const { container } = render(<MarkdownText text={source} />) + + const links = screen.getAllByRole('link') + expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl]) + for (const link of links) { + expect(link.closest('code')).not.toBeNull() + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + } + links[0]?.focus() + expect(document.activeElement).toBe(links[0]) + expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull() + expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull() + expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull() + const paddedCode = [...container.querySelectorAll('code')] + .find(code => code.textContent === ` ${localUrl} `) + expect(paddedCode?.querySelector('a')).toBeNull() + expect(container.querySelector('pre code a')).toBeNull() + }) + + it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => { + const data: { micromarkExtensions?: Extension[] } = {} + const processor = { data: () => data } + remarkCjkFriendlyStrong.call(processor) + remarkCjkFriendlyStrong.call(processor) + + expect(data.micromarkExtensions).toHaveLength(2) + const construct = data.micromarkExtensions?.[0]?.text?.[42] + const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize + expect(tokenizer).toBeTypeOf('function') + expect(() => tokenizer?.call({ + parser: { constructs: { attentionMarkers: {} } }, + previous: null, + } as never, {} as never, () => undefined, () => undefined)).toThrow( + 'micromark CommonMark attention markers are unavailable', + ) + }) + it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => { for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />) @@ -171,6 +272,161 @@ describe('MarkdownText', () => { expect(container.querySelector('a')).toBeNull() }) + it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => { + const source = [ + 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + '', + '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + ].join('\n') + const { container } = render(<MarkdownText text={source} />) + + expect(container.querySelectorAll('.katex')).toHaveLength(6) + expect(container.querySelectorAll('.katex-display')).toHaveLength(2) + expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}') + expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent) + .toContain('\\tag{1}') + expect(container.querySelector('.katex-error')).toBeNull() + expect(container.querySelector('table .katex')).not.toBeNull() + }) + + it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => { + const cases = [ + { + source: '\\(\\alpha \\, \\beta\\)', + math: 1, + display: 0, + }, + { + source: String.raw`\\\(x\)`, + math: 1, + display: 0, + value: 'x', + }, + { + source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', + math: 1, + display: 0, + value: '\\frac{1}{5}\n+\\frac{1}{7}', + }, + { + source: '\\[a\\\\\nb\\]', + math: 1, + display: 1, + value: 'a\\\\\nb', + }, + { + source: '> \\[\n> \\frac{1}{5}\n> \\]', + math: 1, + display: 1, + }, + { + source: '- \\[\n \\frac{1}{5}\n \\]', + math: 1, + display: 1, + }, + ] + + for (const item of cases) { + const rendered = render(<MarkdownText text={item.source} />) + expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) + expect(rendered.container.querySelector('.katex-error')).toBeNull() + if ('value' in item) { + expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value) + } + rendered.unmount() + } + + const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />) + expect(literal.container.querySelectorAll('.katex')).toHaveLength(0) + expect(literal.container.querySelector('.katex-display')).toBeNull() + expect(literal.container.textContent).toContain('[x') + }) + + it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => { + const cases = [ + { source: '$$\n\\theta\n$$', math: 1, display: 1 }, + { source: '$$$\\theta$$$', math: 1, display: 0 }, + { source: '$$a$b\nc', math: 0, display: 0 }, + { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 }, + { source: '\\(\\theta', math: 0, display: 0 }, + { source: String.raw`\(a\\)`, math: 0, display: 0 }, + { source: '\\[\n\\[', math: 0, display: 0 }, + { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 }, + ] + + for (const item of cases) { + const rendered = render(<MarkdownText text={item.source} />) + expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) + expect(rendered.container.querySelector('.katex-error')).toBeNull() + rendered.unmount() + } + }) + + it('lets display math interrupt an open paragraph', () => { + for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) { + const rendered = render(<MarkdownText text={source} />) + expect(rendered.container.querySelectorAll('p')).toHaveLength(1) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) + rendered.unmount() + } + }) + + it('leaves a dollar block with trailing text to upstream inline math', () => { + const { container } = render(<MarkdownText text="$$x$$ trailing" />) + + expect(container.querySelectorAll('.katex')).toHaveLength(1) + expect(container.querySelector('.katex-display')).toBeNull() + expect(container.querySelector('annotation')?.textContent).toBe('x') + expect(container.textContent).toContain('trailing') + }) + + it('renders escaped dollars and even backslash pairs before closing fences', () => { + const source = [ + String.raw`$$100\$$$`, + '', + String.raw`\(a\\\)`, + '', + String.raw`\[b\\\]`, + ].join('\n') + const { container } = render(<MarkdownText text={source} />) + const values = [...container.querySelectorAll('annotation')].map(node => node.textContent) + + expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`]) + expect(container.querySelector('.katex-error')).toBeNull() + }) + + it('bounds fallback work for repeated unclosed backslash delimiters', () => { + const startedAt = performance.now() + const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />) + + expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(container.querySelector('.katex')).toBeNull() + }) + + it('leaves TeX-looking fenced code literal', () => { + const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```' + const { container } = render(<MarkdownText text={source} />) + + expect(container.querySelector('.katex')).toBeNull() + expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]') + expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$') + }) + + it('registers the compatibility extension on a bare remark processor', () => { + const data: { micromarkExtensions?: Extension[] } = {} + remarkMathCompatibility.call({ data: () => data }) + + expect(data.micromarkExtensions).toHaveLength(1) + }) + it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial' const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$' diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 5dc9a1a378..72b33ce12c 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -43,8 +43,9 @@ describe('Tooltip', () => { const bubble = screen.getByRole('tooltip') expect(bubble.textContent).toBe('Open sidebar') expect(bubble.getAttribute('data-side')).toBe('right') - // jsdom rects are all-zero: right placement lands at the +10 gutter. - expect(bubble.style.left).toBe('10px') + // jsdom rects are all-zero: right placement lands at the +10 gutter, then + // the zero-width measured rect clamps to the 12px edge margin (10 + 12). + expect(bubble.style.left).toBe('22px') expect(bubble.style.top).toBe('0px') fireEvent.mouseLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() @@ -60,12 +61,100 @@ describe('Tooltip', () => { fireEvent.focus(anchor) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-side')).toBe('bottom') - expect(bubble.style.left).toBe('0px') + // Zero-width jsdom rect at x=0 clamps to the 12px edge margin. + expect(bubble.style.left).toBe('12px') expect(bubble.style.top).toBe('8px') fireEvent.blur(anchor) expect(screen.queryByRole('tooltip')).toBeNull() }) + // jsdom's default rects are all-zero, so the clamp tests stub the measured + // rect (anchor and bubble share the prototype stub) and derive expectations + // from it: pos.x = anchor center, then shifted by the measured overflow. + const rect = (left: number, right: number): DOMRect => + ({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) }) + + it('clamps a bubble overflowing the right viewport edge back inside', () => { + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100)) + try { + render( + <Tooltip label="Wide" side="bottom"> + <button type="button">anchor</button> + </Tooltip>, + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + // pos.x = 1000 (anchor center); measured right edge 1100 overflows the + // 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp + // shifts left to 912. + expect(screen.getByRole('tooltip').style.left).toBe('912px') + } finally { + spy.mockRestore() + } + }) + + it('reclamps after label and viewport width changes', () => { + const originalWidth = window.innerWidth + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000) + return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950) + }) + try { + const view = render( + <Tooltip label="Wide" side="bottom"> + <button type="button">anchor</button> + </Tooltip>, + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + expect(screen.getByRole('tooltip').style.left).toBe('862px') + + view.rerender( + <Tooltip label="Short" side="bottom"> + <button type="button">anchor</button> + </Tooltip>, + ) + expect(screen.getByRole('tooltip').style.left).toBe('950px') + + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 }) + fireEvent(window, new Event('resize')) + expect(screen.getByRole('tooltip').style.left).toBe('888px') + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth }) + spy.mockRestore() + } + }) + + it('clamps a bubble past the left viewport edge back inside', () => { + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80)) + try { + render( + <Tooltip label="Wide" side="bottom"> + <button type="button">anchor</button> + </Tooltip>, + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + // pos.x = 30 (anchor center); measured left edge -20 underflows the + // 12px safe margin by 32, so the clamp shifts right to 62. + expect(screen.getByRole('tooltip').style.left).toBe('62px') + } finally { + spy.mockRestore() + } + }) + + it('supports top placement for anchors at the viewport bottom', () => { + render( + <Tooltip label="Above" side="top"> + <button type="button">anchor</button> + </Tooltip>, + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('top') + // jsdom rects are all-zero: top placement lands at the -8 gutter and the + // zero-width measured rect clamps left to the 12px edge margin. + expect(bubble.style.left).toBe('12px') + expect(bubble.style.top).toBe('-8px') + }) + it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { const onMouseEnter = vi.fn() const onMouseLeave = vi.fn() diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index f1ef187514..bca51d908b 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-question/README.md README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c -README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228 +README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691 diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 3c2b12b30d..6344327d26 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,9 +4,9 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 -若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 +若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review`——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形——没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定——都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index fbf1dbb098..7416596284 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-question/src/client/PlanReviewPanel.module.css b/packages/client/ui-question/src/client/PlanReviewPanel.module.css index 428effab2b..a9b5310d40 100644 --- a/packages/client/ui-question/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-question/src/client/PlanReviewPanel.module.css @@ -9,7 +9,9 @@ .frame { display: flex; justify-content: center; - padding: 6px 24px 10px; + /* Sides = clearance + 16px: the card lands on the shared content width + (input card - 32) at every viewport. */ + padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px; } .card { @@ -17,7 +19,7 @@ overflow: hidden; flex-direction: column; width: 100%; - max-width: 776px; + max-width: var(--dsh-chat-content-width); /* Composer seat sits in a fixed-height conversation column (overflow hidden): cap the card against the viewport and scroll the plan, so the strip and the decision row stay reachable on a long plan. */ @@ -93,11 +95,19 @@ gap: 8px; } -@media (max-width: 720px) { - .frame { - padding: 6px 10px 10px; - } +/* The discuss verb stays a quiet text button beside the two decision + capsules: 14px glyph against the 14px label with a slightly wider gap, so + the icon reads as a prefix rather than a peer-sized control. */ +.discuss { + gap: 6px; + color: var(--dsw-alias-label-secondary); +} +.discuss:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); +} + +@media (max-width: 720px) { .card { border-radius: 16px; } diff --git a/packages/client/ui-question/src/client/PlanReviewPanel.tsx b/packages/client/ui-question/src/client/PlanReviewPanel.tsx index 020df9c82c..9f7bf6e18a 100644 --- a/packages/client/ui-question/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-question/src/client/PlanReviewPanel.tsx @@ -73,21 +73,21 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { <div className={css.feedback} role="status">{error}</div> <div className={css.actions}> <Button - size="sm" variant="ghost" icon={<IconEditOutline16 />} + variant="ghost" className={css.discuss} icon={<IconEditOutline16 size={14} />} disabled={busy} onClick={() => { settle(() => pending.cancel()) }} > {t('plan.discuss')} </Button> {decline !== undefined && ( <Button - size="sm" variant="outline" {...tooltip(decline.description)} + variant="outline" {...tooltip(decline.description)} disabled={busy} onClick={() => { decide(decline.label) }} > {t('plan.decline')} </Button> )} <Button - size="sm" variant="primary" {...tooltip(review.approve.description)} + variant="primary" {...tooltip(review.approve.description)} disabled={busy} onClick={() => { decide(review.approve.label) }} > {t('plan.approve')} diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index c96aefa0ef..c0b83182d2 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -1,9 +1,10 @@ -/* The takeover seats where the input card sits, so the frame mirrors the - InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */ +/* The takeover seats where the input card sits, centered on the InputBar + axis at the shared content width (input card - 32): sides = clearance + + 16px so the relation also holds on narrow viewports. */ .frame { display: flex; justify-content: center; - padding: 6px 32px 10px; + padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px; } /* Figma Input 973:36348 body over the 1019:36938 header: no banner strip — @@ -12,7 +13,7 @@ display: flex; flex-direction: column; width: 100%; - max-width: 800px; + max-width: var(--dsh-chat-content-width); /* Composer seat sits in a fixed-height conversation column (overflow hidden): cap the card against the viewport and scroll the option list so header and footer actions stay reachable on long batches. */ @@ -366,10 +367,6 @@ } @media (max-width: 720px) { - .frame { - padding: 6px 10px 10px; - } - .card { border-radius: 16px; } diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 8cc25aeb88..73d2d656b1 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -36,13 +36,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'question' -/** - * Required services (cordis fiber inject). 'conversation' is an ordering - * edge, not a call dependency: the 'conversation.composer' chain slot is - * declared by ui-conversation's apply, and register() into an undeclared - * slot throws — service waiting orders this apply after the declaring one. - */ -export const inject = ['slots', 'conversation', 'locale'] +/** Required services: the slot registry and the question composer's copy. */ +export const inject = ['slots', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -58,11 +53,8 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') - ctx.effect( - () => ctx.slots.register( - { name: 'conversation.composer', select: selectQuestion, locale: NS }, - QuestionComposer, - ), - 'ui-question: composer chain registration', - ) + ctx.slots.inject('conversation.composer', () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + )) } diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 0acc7fac82..01b077a29a 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -2,7 +2,7 @@ * apply wiring on a real cordis Context + SlotsService: QuestionComposer * registered as the `question` entry of the conversation-declared composer * slot with ZERO business face (data and verbs ride the dispatched carrier), - * load-order fail-loud, and fiber-teardown unregistration. Component and + * declaration-aware activation, and fiber-teardown unregistration. Component and * domain-face behavior is covered props-direct in question-composer.spec.tsx; * no renderer machinery here. */ @@ -22,27 +22,28 @@ async function bench() { { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, () => null, ) - // 'conversation' inject is an ordering edge (the declaring plugin provides - // it after declaring the chain); the bench declares the chain itself. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation', 'locale']) + expect(inject).toEqual(['slots', 'locale']) }) - it('fails loud when no live entry has declared the composer slot', async () => { + it('waits until a live entry declares the composer slot', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - // Satisfy the ordering inject without declaring the chain: apply must - // then hit the undeclared-slot throw, not sit waiting on the service. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) - await expect(ctx.plugin({ inject: [...inject], apply })) - .rejects.toThrow(/slot "conversation.composer" is not declared/) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(0) + ctx.slots.register( + { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, + () => null, + ) + await Promise.resolve() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(1) }) it('registers the question entry: routing selector, no inject face', async () => { diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index b14bd3ded3..9fe338ea47 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 0202d596f509feeba39a38254e8bab2fae27b649 -README.zh.md: adec73edda00d34e209772f0bcc54a994f593997 +README.md: 29e48d193d24644f37d219b4df44a8fedf062e53 +README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0202d596f5..29e48d193d 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. +Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index adec73edda..17ebc9e8ab 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 +设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 8f78ce6acb..a3e8973280 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css new file mode 100644 index 0000000000..c4bb82e12f --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css @@ -0,0 +1,16 @@ +.action { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.error { + max-width: 180px; + overflow: hidden; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx new file mode 100644 index 0000000000..bfe8a813bb --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx @@ -0,0 +1,50 @@ +/** Optional settings-header action for opening a file-backed Host document. */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts' +import css from './SettingsDocumentAction.module.css' + +/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */ +export interface SettingsDocumentActionInjected { + /** Provider metadata and action state owner. */ + controller: SettingsDocumentStore + /** Bound selector hook for the controller snapshot. */ + useSnapshot: SnapshotSelectorHook<SettingsDocumentState> +} + +/** Header-action owner share, localized copy, and the registrant's state face. */ +export type SettingsDocumentActionProps = + PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected + +/** + * Render the open-document action only after Host metadata confirms document availability. + * @param props - header owner props, localized copy, and injected document state. + * @returns the action, or null while unavailable or unresolved. + */ +export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode { + const state = useSnapshot(snapshot => snapshot) + + useEffect(() => { + void controller.load() + }, [controller]) + + if (state.status !== 'ready') return null + + return ( + <div className={css.action}> + {state.error === null ? null : <span className={css.error} role="alert">{t('openDocument.error')}</span>} + <Button + variant="outline" + size="sm" + disabled={state.opening} + onClick={() => { void controller.open() }} + > + {t('openDocument')} + </Button> + </div> + ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 87b05e86cb..9d57a0eba9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,12 +1,11 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section, and the `settings` dictionaries. - * Feature-owned rows and sections stay with their features. + * chrome content, local-document action, General section, and `settings` + * dictionaries. Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). @@ -15,6 +14,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' +import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' @@ -27,6 +29,9 @@ export type { export type { GeneralSectionComponentProps, } from './GeneralSection.tsx' +export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx' +export type { SettingsDocumentState } from './settings-document-store.ts' +export { SettingsDocumentStore } from './settings-document-store.ts' export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx' export type { WelcomeNoticeState } from './welcome-store.ts' export type { SettingsKey } from './locales.ts' @@ -44,7 +49,7 @@ const NS = 'settings' /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. + * constrained; registrations depend on their slots through `slots.inject()`. */ export const inject = ['slots', 'locale', 'connection'] @@ -61,6 +66,15 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + const documentController = connection.isLoopback + ? new SettingsDocumentStore(connection.api) + : undefined + const documentInjected = documentController === undefined + ? undefined + : (() => { + const useSnapshot = bindSnapshotSelector(documentController.store) + return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot }) + })() const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ @@ -75,40 +89,41 @@ export function apply(ctx: ClientContext): void { } const disposers = [ ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), + ctx.on('connection/reset', () => { + refresh() + refreshDocumentIfLoaded(documentController) + }), ] return () => { for (const dispose of disposers) dispose() } - }, 'ui-settings-general: welcome invalidations') - ctx.effect(() => { - const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => - ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) - const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => - ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) - const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => - ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) - const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => - ctx.slots.register({ - name: 'settings.section', - id: 'general', - order: 0, - label: () => t('general.nav'), - locale: NS, - children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, - }, GeneralSection)) - const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () => - ctx.slots.register({ - name: 'settings.onboarding', - id: 'welcome-notice', - order: -100, - locale: NS, - inject: welcomeInjected, - }, WelcomeNotice)) - return () => { - trigger.dispose() - header.dispose() - close.dispose() - general.dispose() - welcome.dispose() - } - }, 'ui-settings-general: chrome, section, and onboarding registrations') + }, 'ui-settings-general: metadata invalidations') + ctx.slots.inject('settings.trigger', () => + ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) + ctx.slots.inject('settings.header', () => + ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + if (documentInjected !== undefined) { + ctx.slots.inject('settings.action', () => ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) + } + ctx.slots.inject('settings.close', () => + ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: () => t('general.nav'), + locale: NS, + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + }, GeneralSection)) + ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ + name: 'settings.onboarding', + id: 'welcome-notice', + order: -100, + locale: NS, + inject: welcomeInjected, + }, WelcomeNotice)) } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index ef300e8e1f..41a44eae3f 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,6 +6,8 @@ export const zh = { 'trigger': '设置', 'title': '设置', 'close': '关闭', + 'openDocument': '打开配置文件', + 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', 'welcome.title': WELCOME_NOTICE_COPY.zh.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], @@ -24,6 +26,8 @@ export const en = { 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', + 'openDocument': 'Open configuration file', + 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', 'welcome.title': WELCOME_NOTICE_COPY.en.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts new file mode 100644 index 0000000000..eb1d9590b9 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -0,0 +1,96 @@ +/** State owner for the optional local settings-document action. */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Browser state of the Host-owned settings document. */ +export interface SettingsDocumentState { + /** Metadata-loading phase; unavailable means the provider has no local document or the read failed. */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** Whether one native-open request is in flight. */ + opening: boolean + /** Last metadata/native-open diagnostic; UI exposes only localized copy. */ + error: string | null +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Loads local-document availability and invokes the pathless Host-owned open operation. */ +export class SettingsDocumentStore { + /** uSES-safe state source shared by the registered header action. */ + readonly store: SnapshotStore<SettingsDocumentState> = createSnapshotStore({ + status: 'idle', opening: false, error: null, + }) + + private generation = 0 + + /** + * @param api - loopback settings wire face that reports and opens the provider document. + */ + constructor(private readonly api: Pick<IApiClient, 'settings'>) {} + + /** + * Load whether the current provider owns a local document. + * @returns after the latest metadata response updates the store. + */ + async load(): Promise<void> { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const { result } = await this.api.settings.describe({}) + if (generation !== this.generation) return + if (!result.ok) { + this.store.update((state) => { + state.status = 'unavailable' + state.error = result.error.message + }) + return + } + this.store.update((state) => { + state.status = result.value.hasDocument ? 'ready' : 'unavailable' + state.error = null + }) + } catch (error) { + if (generation !== this.generation) return + this.store.update((state) => { + state.status = 'unavailable' + state.error = messageOf(error) + }) + } + } + + /** + * Open the loaded document once; concurrent gestures collapse behind the in-flight action. + * @returns after the native-open request settles, or immediately when unavailable/already opening. + */ + async open(): Promise<void> { + const current = this.store.getSnapshot() + if (current.status !== 'ready' || current.opening) return + this.store.update((state) => { + state.opening = true + state.error = null + }) + try { + const response = await this.api.settings.openDocument({}) + if (!response.result.ok) throw new Error(response.result.error.message) + } catch (error) { + this.store.update((state) => { state.error = messageOf(error) }) + } finally { + this.store.update((state) => { state.opening = false }) + } + } +} + +/** + * Refresh document availability after reconnect only when a surface has already requested it. + * @param controller - optional loopback document state owner. + */ +export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void { + if (controller === undefined || controller.store.getSnapshot().status === 'idle') return + void controller.load() +} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index d13ecc5cb8..c5917bd83b 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -16,8 +16,9 @@ export const inject = ['invariants'] /** * No runtime invariant: the settings seam validates and publishes the durable - * welcome section, while slot conflicts fail loud in the slot core; this - * package owns no additional event/data relationship between those systems. + * welcome section, while slot conflicts fail loud in the slot core. The local + * document action is browser state over typed RPC responses and is covered by + * store/component tests rather than a Cordis runtime relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 73f6d8207e..ae06c22c03 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ +/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' @@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -/** The five seats this plugin fills (slot name → expected component). */ +/** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], ['settings.header', HeaderContent], + ['settings.action', SettingsDocumentAction], ['settings.close', CloseLabel], ['settings.section', GeneralSection], ['settings.onboarding', WelcomeNotice], @@ -36,6 +39,7 @@ async function bench(isLoopback = true) { ok: true as const, value: { writable: true, + hasDocument: true, namespaces: [{ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, @@ -47,11 +51,18 @@ async function bench(isLoopback = true) { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } + const settingsOpenDocument = vi.fn(() => Promise.resolve({ + rpcId: 'settings-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + ctx.provide('connection', { + api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, + isLoopback, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument } } -/** Declare the shell's four child slots the way ui-settings' entry does. */ +/** Declare the shell's six child slots the way ui-settings' entry does. */ function declare(slots: SlotsService): () => void { return slots.register( { @@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void { children: { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, @@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) }) - it('fills all five seats for declarations before or after apply', async () => { + it('fills all six seats for declarations before or after apply', async () => { const before = await bench() declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() @@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => { expect(before.slots.entries('settings.general.item')).toEqual([]) const welcome = before.slots.entries('settings.onboarding')[0]! expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 }) + const action = before.slots.entries('settings.action')[0]! + const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() + expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') + expect(actionInjected.useSnapshot).toEqual(expect.any(Function)) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') @@ -159,10 +175,25 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) }) + it('refreshes loaded document availability on reconnect without reading it eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.action')[0]! + const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)() + b.ctx.emit('connection/reset') + expect(b.settingsDescribe).not.toHaveBeenCalled() + await controller.load() + expect(b.settingsDescribe).toHaveBeenCalledOnce() + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) + }) + it('keeps remote welcome acknowledgement process-local', async () => { const b = await bench(false) declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() const entry = b.slots.entries('settings.onboarding')[0]! const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() @@ -170,6 +201,9 @@ describe('ui-settings-general apply', () => { await expect(controller.acknowledge()).resolves.toBe(true) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) expect(b.settingsDescribe).not.toHaveBeenCalled() + expect(b.slots.entries('settings.action')).toEqual([]) + await fiber.dispose() + for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([]) }) it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index db6be78ccd..447dd7e9c7 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -54,3 +57,95 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) + +describe('SettingsDocumentAction', () => { + it('appears only for a file-backed provider and requests its Host-owned document', async () => { + const openDocument = vi.fn(() => Promise.resolve({ + rpcId: 'document-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [] }, + }, + })), + openDocument, + }, + } as never) + render(<SettingsDocumentAction + {...kit} + t={t} + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + />) + const action = await screen.findByRole('button', { name: 'Open configuration file' }) + fireEvent.click(action) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + }) + + it('stays absent without a document and retries availability after remount', async () => { + const describe = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'document-action-absent' as never, + result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }, + }) + .mockResolvedValueOnce({ + rpcId: 'document-action-ready' as never, + result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, + }) + const controller = new SettingsDocumentStore({ + settings: { + describe, + openDocument: vi.fn(), + }, + } as never) + const first = render(<SettingsDocumentAction + {...kit} + t={t} + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + />) + await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') }) + expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull() + first.unmount() + render(<SettingsDocumentAction + {...kit} + t={t} + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + />) + expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy() + expect(describe).toHaveBeenCalledTimes(2) + }) + + it('keeps the action available and reports a native-open failure', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [] }, + }, + })), + openDocument: vi.fn(() => Promise.resolve({ + rpcId: 'document-open-failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + })), + }, + } as never) + render(<SettingsDocumentAction + {...kit} + t={t} + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + />) + fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' })) + expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file') + expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts new file mode 100644 index 0000000000..9be3cf3252 --- /dev/null +++ b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' + +function response(hasDocument = false): RpcResponse<{ + writable: boolean + hasDocument: boolean + namespaces: [] +}> { + return { + rpcId: 'settings-document' as never, + result: { + ok: true, + value: { writable: true, hasDocument, namespaces: [] }, + }, + } +} + +function opened(): RpcResponse<{ opened: true }> { + return { + rpcId: 'settings-open' as never, + result: { ok: true, value: { opened: true } }, + } +} + +function describeFailed(message: string): RpcResponse<never> { + return { + rpcId: 'settings-document-failed' as never, + result: { ok: false, error: { code: 'internal', message, details: {} } }, + } +} + +describe('SettingsDocumentStore', () => { + it('loads provider metadata and asks the settings domain to open its document', async () => { + const describe = vi.fn(() => Promise.resolve(response(true))) + const openDocument = vi.fn(() => Promise.resolve(opened())) + const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ + status: 'ready', opening: false, error: null, + }) + await controller.open() + expect(openDocument).toHaveBeenCalledWith({}) + }) + + it('marks absent or failed metadata unavailable without opening anything', async () => { + const openDocument = vi.fn(() => Promise.resolve(opened())) + const absent = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response()), openDocument }, + } as never) + await absent.load() + await absent.open() + expect(absent.store.getSnapshot().status).toBe('unavailable') + expect(openDocument).not.toHaveBeenCalled() + + const failed = new SettingsDocumentStore({ + settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + } as never) + await failed.load() + expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) + + const rejected = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + } as never) + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ + status: 'unavailable', error: 'provider failed', + }) + }) + + it('collapses concurrent open gestures and recovers after a failure', async () => { + let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve })) + const controller = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response(true)), openDocument }, + } as never) + await controller.load() + const first = controller.open() + const second = controller.open() + expect(openDocument).toHaveBeenCalledOnce() + resolveOpen({ + rpcId: 'settings-open-failed' as never, + result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, + }) + await Promise.all([first, second]) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'no default editor', + }) + }) + + it('ignores stale metadata completions and reports non-Error native failures', async () => { + let resolveFirst!: (value: ReturnType<typeof response>) => void + const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve }) + const describe = vi.fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response(true)) + let rejectOpen!: (reason?: unknown) => void + const controller = new SettingsDocumentStore({ + settings: { + describe, + openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + }, + } as never) + const stale = controller.load() + await controller.load() + resolveFirst(response()) + await stale + expect(controller.store.getSnapshot().status).toBe('ready') + const opening = controller.open() + rejectOpen('native unavailable') + await opening + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'native unavailable', + }) + + let rejectFirst!: (error: Error) => void + const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject }) + const caught = new SettingsDocumentStore({ + settings: { + describe: vi.fn() + .mockReturnValueOnce(rejectedFirst) + .mockResolvedValueOnce(response(true)), + openDocument: vi.fn(), + }, + } as never) + const staleRejection = caught.load() + await caught.load() + rejectFirst(new Error('stale offline')) + await staleRejection + expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + }) +}) diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx index 9ede91859b..74feea89a5 100644 --- a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx +++ b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx @@ -23,6 +23,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom settings: { describe: () => Promise.resolve(response({ writable: true, + hasDocument: false, namespaces: [{ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 45e4ca5590..04f2608763 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -53,7 +53,7 @@ describe('WelcomeNoticeStore', () => { ] as const) { const api = { settings: { - describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))), + describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))), }, } const controller = new WelcomeNoticeStore(api as never) @@ -101,7 +101,7 @@ describe('WelcomeNoticeStore', () => { rpcId: 'failed' as never, result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } }, }), - () => Promise.resolve(ok({ writable: true, namespaces: [] })), + () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), ]) { const controller = new WelcomeNoticeStore({ settings: { describe } } as never) await controller.load() @@ -112,6 +112,7 @@ describe('WelcomeNoticeStore', () => { const controller = new WelcomeNoticeStore({ settings: { describe: () => Promise.resolve(ok({ writable: true, + hasDocument: false, namespaces: [{ ...namespace(), value }], })) }, } as never) @@ -133,18 +134,20 @@ describe('WelcomeNoticeStore', () => { const first = deferred<ReturnType<typeof ok>>() const describe = vi.fn() .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] }))) + .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] }))) const controller = new WelcomeNoticeStore({ settings: { describe } } as never) const stale = controller.load() await controller.load() - first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })) + first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })) await stale expect(controller.store.getSnapshot().acknowledged).toBe(false) const failed = deferred<ReturnType<typeof ok>>() describe .mockImplementationOnce(() => failed.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))) + .mockImplementationOnce(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], + }))) const staleFailure = controller.load() await controller.load() failed.reject('stale failure') @@ -154,7 +157,7 @@ describe('WelcomeNoticeStore', () => { it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => { const write = deferred<ReturnType<typeof ok>>() - const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] }))) + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] }))) const controller = new WelcomeNoticeStore({ settings: { mutate: () => write.promise, describe }, } as never) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 668853e41f..989fb18e64 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c -README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d +README.md: de78d599b7833179339ceeb680fbd665b056bd83 +README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 14c78c8346..de78d599b7 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8831842d03..8ae3bdf34f 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 @@ -12,7 +12,7 @@ #### KV Cache 影响 -无;该包(package)既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供方请求。 ## 已知限制与暂缓事项 diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d4b872173f..6fa2fdc8cb 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 817ab38d9a..72c188e019 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -167,12 +167,22 @@ flex: none; display: flex; align-items: flex-start; - justify-content: flex-end; + justify-content: space-between; + gap: 8px; height: 54px; padding: 20px 14px 8px 10px; box-sizing: border-box; } +.actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + /* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ .close { display: inline-flex; diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 3eefbd4ef1..45055753ac 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP </nav> <div className={css.content}> <div className={css.header}> + <div className={css.actions}>{renderSlot('settings.action', {})}</div> <button ref={closeButton} type="button" className={css.close} onClick={onClose}> <IconCloseOutline16 size={14} /> <span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 4d5e48d80f..e585fa5b6f 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -2,8 +2,8 @@ * Settings shell slot contract — the canonical home of every settings slot * type. The shell is a pure composition face with zero copy of its own: it * occupies the sidebar-owned `sidebar.settings` hole and declares the slots - * below; ALL text (trigger label, panel title, close aria, section content) - * arrives from registrants. A feature owns its settings surface — adding a + * below; ALL text (trigger label, panel title, header actions, close aria, + * section content) arrives from registrants. A feature owns its settings surface — adding a * setting never means editing the shell; copy that belongs to no single * feature (chrome, the General section) is owned by ui-settings-general. */ @@ -29,6 +29,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * Absent contribution leaves the heading empty. */ 'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps } + /** + * Optional actions rendered in the content-column header before Close. + * Registrants own visibility, behavior, copy, and failure presentation; + * the shell supplies only the ordered render site. + */ + 'settings.action': { kind: 'list'; scope: 'root'; owner: SettingsHeaderOwnerProps } /** * The close button's visually-hidden label text (the button itself — * icon, geometry, focus — is shell chrome). Absent contribution leaves @@ -125,6 +131,11 @@ export type SettingsRootInjected = { export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots< - 'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section' | 'settings.onboarding' + | 'settings.trigger' + | 'settings.header' + | 'settings.action' + | 'settings.close' + | 'settings.section' + | 'settings.onboarding' > & InjectFace<SettingsRootInjected> diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 18719bb042..815a62f24c 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -12,7 +12,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // read (nav labels may be locale-following thunks; the shell still ships no // copy of its own and takes no hard locale dependency). import type {} from '@deepseek-ai/dsh-client-locale/client' -import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, } from './contract/slots.ts' @@ -27,8 +27,8 @@ export type { /** * Required services (cordis fiber inject). The target slot is declared by * ui-sidebar's apply, whose activation order relative to this one is NOT - * constrained (dshClient.inject edges are informational); registration goes - * through declaration-aware deferral. + * constrained (dshClient.inject edges are informational); registration + * depends on the slot through `slots.inject()`. */ export const inject = ['slots'] @@ -96,19 +96,16 @@ export function apply(ctx: ClientContext): void { }, }, }) - ctx.effect(() => { - const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => - ctx.slots.register({ - name: 'sidebar.settings', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: injected, - }, SettingsRoot)) - return () => { deferred.dispose() } - }, 'ui-settings: shell registration') + ctx.slots.inject('sidebar.settings', () => ctx.slots.register({ + name: 'sidebar.settings', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: injected, + }, SettingsRoot)) } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 48b63bc0a0..c46a5bf25c 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */ +/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,10 +24,11 @@ function injectedOf(slots: SlotsService): SettingsRootInjected { return (entry.inject as () => SettingsRootInjected)() } -/** The shell's five child declarations (chrome, sections, and onboarding overlays). */ +/** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */ const CHILD_SPECS = { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, @@ -38,7 +39,7 @@ describe('ui-settings apply', () => { expect(inject).toEqual(['slots']) }) - it('registers the shell and declares the five child slots, before or after the declaration', async () => { + it('registers the shell and declares every child slot, before or after the declaration', async () => { const before = await bench() declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() @@ -124,7 +125,7 @@ describe('ui-settings apply', () => { } }) - it('unregisters the shell and collapses all five child slots on teardown', async () => { + it('unregisters the shell and collapses every child slot on teardown', async () => { const b = await bench() declare(b.slots) const fiber = b.ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 0ddb8f98d9..900c66d381 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -14,6 +14,7 @@ type Step = { id: string; order: number } const SEAT_CONTENT: Record<string, string> = { 'settings.trigger': 'Settings', 'settings.header': 'Settings Title', + 'settings.action': 'Open configuration file', 'settings.close': 'Close', } @@ -114,6 +115,13 @@ describe('SettingsPanel chrome seats', () => { expect(close.hasAttribute('aria-label')).toBe(false) expect(close.textContent).toContain('Close') }) + + it('renders header actions before the shell-owned close control', () => { + const { renderSlot } = mount() + openPanel() + expect(screen.getByText('Open configuration file')).toBeTruthy() + expect(renderSlot).toHaveBeenCalledWith('settings.action', {}) + }) }) describe('SettingsPanel close paths', () => { diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 1bd10f89a6..cc563215dc 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 5bb697b3d2f9b5eaea9c382765d2510fa24806ce -README.zh.md: 302f66c540774b1f209fc797201e41c56b849310 +README.md: 45ae267d98b17bbc612cf932f5b95b42ba6ff4bf +README.zh.md: 436baf0b2d50934ce4813ac53c532006d9d0d4fb diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 5bb697b3d2..45ae267d98 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -12,7 +12,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. -The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). +The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration. ## Model Experience @@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. -- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available. +- **Group-by supports Workspace only** — Update and Status are not available strategies. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 302f66c540..436baf0b2d 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -12,7 +12,7 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work 页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。 -`/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型:SidebarRoot、行组件和树派生均属于内部实现(slot 注册通过闭包引用它们;测试直接导入 src 路径)。 +`/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型;SidebarRoot、行组件和树派生仍由 slot 注册封装在包内。 ## 模型体验 @@ -24,6 +24,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 -- **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 +- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7bff9523ed..dfbeefbe44 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c4c839f5fb..711f4171e4 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -16,6 +16,10 @@ background: var(--dsw-specific-sidebar-fill); color: var(--dsw-alias-label-primary); font-size: 14px; + /* The sidebar is elevated above the conversation surface, so a revealed + scrollbar uses the l2 pair. .quietBars hides it without changing layout. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } /* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 2b0c9bc2a8..f464066f56 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -141,7 +141,7 @@ export function SidebarRoot({ )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */} - <Tooltip label={t('toggle.open')} disabled={wide}> + <Tooltip label={collapsed ? t('toggle.open') : t('toggle.collapse')} delayMs={500}> <button type="button" className={clsx(css.iconButton, css.toggle)} @@ -155,7 +155,8 @@ export function SidebarRoot({ </Tooltip> </div> - <Tooltip label={t('session.new.label')} disabled={wide}> + {/* Expanded, the button carries its own label — tooltip only on the rail. */} + <Tooltip label={t('session.new.label')} delayMs={500} disabled={wide}> <button type="button" className={css.newSession} diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 2c06d7b5c5..059a5d8986 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md README.md: fc83ae47dc83e72d60f382892aa678989902d217 -README.zh.md: 60f2c258acdb7e19148e05f19061e0e3f2c28ee7 +README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 60f2c258ac..e103db812d 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,7 +14,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且不确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 #### Token 影响 @@ -22,10 +22,10 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包(package)绝不改写较早的请求 token。 +仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 -- **skill 加载不确定**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 +- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 - **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 4ada43cd1d..20d61cdb53 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -54,8 +54,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index 5f1f5f23c4..340f420504 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md -README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2 -README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628 +README.md: efb289aaf6b4a442b00e7a0b26ed044b7b061070 +README.zh.md: e2d99514d29b1b5aeeb1d7f42136ec73d961f3e7 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index 5d277a83c5..efb289aaf6 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -22,4 +22,4 @@ None; this package neither assembles nor sends a provider request. - **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need). - **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships. -- **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it. +- **Overlay SlotMap merge home is split from slot ownership** — the sole `conversation.input.overlay` merge lives here, while ui-conversation owns its anchor, children declaration, and lifecycle because the dependency direction is ui-conversation → ui-slash. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 195aec6b76..e2d99514d2 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -16,10 +16,10 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类 #### KV Cache 影响 -无;该包(package)既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供方请求。 ## 已知限制与暂缓事项 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。 - **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;与设计系统图标枚举(iconFile 五变体家族)的接入将在该枚举交付后完成。 -- **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。 +- **overlay 的 SlotMap 合并归属与 slot 所有权分离**:唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 负责其锚点、children 声明和生命周期,因为依赖方向是 ui-conversation → ui-slash。 diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index c7448532df..7562a536e3 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -61,8 +61,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-slash/src/client/MenuView.module.css b/packages/client/ui-slash/src/client/MenuView.module.css index 9a781e6b14..527d1b7c37 100644 --- a/packages/client/ui-slash/src/client/MenuView.module.css +++ b/packages/client/ui-slash/src/client/MenuView.module.css @@ -9,8 +9,11 @@ bottom: calc(100% + 4px); left: 0; z-index: 100; - min-width: 260px; - max-width: 537px; + min-width: min(260px, 100%); + /* 537 is the design cap; the 100% clamp keeps the menu inside the composer + card when a narrow viewport shrinks the card below the cap (the overlay + anchor is exactly the card's width). */ + max-width: min(537px, 100%); /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in MenuView.tsx). */ max-height: 320px; diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index b7829ba95c..bcc20a8679 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -55,14 +55,10 @@ export const inject = ['sessions', 'locale'] export function apply(ctx: ClientContext): void { ctx.plugin(SlashService) ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-slash: menu dictionaries') - // Conditional mount: 'conversation.input.overlay' is declared by the - // conversation composer entry, and the conversation service is mounted - // after that declaration lands on the ledger — its presence is the - // registration-safe signal (same seam as toolview registrants). - ctx.inject(['slots', 'conversation', 'slash', 'sessions'], (scope: ClientContext) => { + ctx.inject(['slots', 'slash', 'sessions'], (scope: ClientContext) => { const slash = scope.slash const sessions = scope.sessions - scope.effect(() => scope.slots.register({ + scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'slash-menu', order: 0, @@ -79,6 +75,6 @@ export function apply(ctx: ClientContext): void { onDismiss: () => { controller.dismiss() }, } }, - }, MenuView), 'ui-slash: MenuView overlay registration') + }, MenuView)) }) } diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index 7121f79441..c8d65f10c8 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -1,12 +1,11 @@ /** * apply wiring on a real cordis Context + SlotsService: SlashService mounts * as ctx.slash once its sessions dependency is up; the MenuView overlay - * registration waits on the conversation seam (ctx.inject scope), lands once - * the declarer is up, resolves the per-session controller from the slot's + * registration follows the slot declaration, resolves the per-session controller from the slot's * sessionId, and unregisters on fiber teardown. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -25,8 +24,8 @@ async function bench() { await ctx.plugin(SlotsService).await() const slots = ctx.get('slots') as SlotsService // Stand-in for the ui-conversation composer entry: declare the overlay - // slot, then provide the conversation service (declaration precedes the - // service exactly as the real apply orders them). + // slot without providing ConversationService, which is not its lifecycle + // signal. slots.register( { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never, () => null, @@ -67,11 +66,7 @@ describe('apply', () => { it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => { const { ctx, slots } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() - expect(slots.entries('conversation.input.overlay')).toHaveLength(0) - - ctx.provide('conversation', {}) - // The inject scope activates asynchronously on the service arrival. - await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) }) + expect(slots.entries('conversation.input.overlay')).toHaveLength(1) const entries = slots.entries('conversation.input.overlay') expect(entries[0]!.options.id).toBe('slash-menu') // Copy rides the standard locale seat, not the business face. @@ -100,8 +95,7 @@ describe('apply', () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - ctx.provide('conversation', {}) - await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) }) + expect(slots.entries('conversation.input.overlay')).toHaveLength(1) await fiber.dispose() expect(slots.entries('conversation.input.overlay')).toHaveLength(0) diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index 66d309f045..dd103f395a 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md -README.md: ed6f052b3a47e08d693928b6763e32427b829467 -README.zh.md: 17c3cbb28defe0c9bc66df417976984be3955b53 +README.md: bb489dea0c3848cf3d501dcf095a65fe1cef9ef6 +README.zh.md: b4a2915b9d85c45ef7c6dccf27761794e21aa65f diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index ed6f052b3a..bb489dea0c 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. ## Model Experience diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index 17c3cbb28d..b4a2915b9d 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包(package)不依赖 React,也不依赖 Cordis。 +Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包不依赖 React,也不依赖 Cordis。 一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot(声明 = 渲染授权 = 运行时规范,三者共用一张表)、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生: @@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发 store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。 -`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。 +`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与折叠时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。 ## 模型体验 diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index d0346ccf16..9c459fd0f8 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -27,9 +27,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts deleted file mode 100644 index 68f509b916..0000000000 --- a/packages/client/ui-slots/src/deferred.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Declaration-aware registration deferral: the shared timing machinery for - * registering into a slot whose declaring entry activates in unconstrained - * order (dshClient.inject edges never sequence apply). Presence is judged on - * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, - * the cascade has already removed the entry while the local disposer went - * stale, and a flag guard would block the re-registration. - */ - -/** Minimal registry face the deferral reads (SlotsService satisfies it). */ -export interface DeferralRegistry { - /** Declared spec lookup (undefined = not declared yet). */ - spec(name: string): unknown - /** Current entries of the slot (component identity is the presence judge). */ - entries(name: string): readonly { component: unknown }[] - /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ - subscribe(name: string, listener: () => void): () => void -} - -/** Handle over one deferred registration. */ -export interface DeferredRegistration { - /** - * Drop the current registration (stale disposers are harmless no-ops) and - * immediately re-attempt — the refresh path for registrants whose options - * carry localized text. - */ - refresh(): void - /** Unsubscribe and unregister (idempotent through the slot core). */ - dispose(): void -} - -/** - * Register into `name` as soon as its declaration is on the ledger, and - * re-register whenever the declaration reappears after a collapse. - * @param registry - the slot registry face. - * @param name - target slot name. - * @param component - the component whose ledger presence marks "registered". - * @param register - performs the actual registration; returns its disposer. - * @param onFailure - owns a registration failure that fires from a LATER - * ledger flush (a declaration landing after two providers deferred, say): - * the deferral first removes its own subscription, then hands the error - * over instead of throwing through the flush — the callback's chance to - * roll back sibling deferrals and surface the conflict on a loud channel. - * Absent, a late failure rethrows out of the flush. - * @returns the deferral handle (dispose in the owning effect's disposer). - * @throws the immediate registration's failure, after removing the - * just-installed subscription — a throwing construction leaves nothing live. - */ -export function deferRegistration( - registry: DeferralRegistry, - name: string, - component: unknown, - register: () => () => void, - onFailure?: (error: unknown) => void, -): DeferredRegistration { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (registry.spec(name) === undefined) return - if (registry.entries(name).some(e => e.component === component)) return - dispose = register() - } - const unsubscribe = registry.subscribe(name, () => { - try { - tryRegister() - } catch (error) { - unsubscribe() - if (onFailure === undefined) throw error - onFailure(error) - } - }) - try { - tryRegister() - } catch (error) { - // A synchronous registration failure (the declared slot is already - // occupied) must not leave the just-installed subscription behind: the - // caller receives no handle to dispose it through. - unsubscribe() - throw error - } - return { - refresh() { - dispose?.() - dispose = undefined - tryRegister() - }, - dispose() { - unsubscribe() - dispose?.() - }, - } -} - -/** - * Defer ONE occupant into several holes as a unit. Construction that throws - * partway (a declared hole already occupied registers synchronously) rolls - * every earlier deferral back before rethrowing; a failure surfacing from a - * LATER ledger flush (holes declared after rival providers activated) rolls - * the whole group back the same way and re-raises the wrapped error on the - * global channel the boot's fail-loud handler owns — never a throw through - * the slot flush, never partial occupancy from the group's owner. - * @param registry - the slot registry face. - * @param names - the target holes (one registration per name). - * @param component - the occupant whose ledger presence marks "registered". - * @param register - performs one hole's registration; returns its disposer. - * @returns the group handle (dispose in the owning effect's disposer). - * @throws the immediate registration's failure, after rolling the group back. - */ -export function deferGroupRegistration<K extends string>( - registry: DeferralRegistry, - names: readonly K[], - component: unknown, - register: (name: K) => () => void, -): { dispose: () => void } { - const deferred: DeferredRegistration[] = [] - const lateFailure = (error: unknown): void => { - for (const entry of deferred) entry.dispose() - queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) - } - try { - for (const name of names) { - deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure)) - } - } catch (error) { - for (const entry of deferred) entry.dispose() - throw error - } - return { dispose: () => { for (const entry of deferred) entry.dispose() } } -} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index f7f31a8ed1..6a20a8b916 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -19,7 +19,6 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' -export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} @@ -457,9 +456,12 @@ interface SlotRecord { spec: SlotSpec<SlotEntryDef> | undefined /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */ declaredBy: string | undefined + /** Monotonic declaration lifetime, distinct from ordinary entry mutations. */ + declarationEpoch: number entries: readonly StoredEntry[] version: number listeners: Set<() => void> + declarationListeners: Set<() => void> } const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) @@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) * * Change propagation contract: versions bump and {@link SlotCore.onMutate} * fires synchronously per mutation (registry state is consistent when they - * fire); {@link SlotCore.subscribe} notifications batch per microtask, so N - * same-tick mutations produce one notification per touched key. + * fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each + * declaration lifetime boundary; {@link SlotCore.subscribe} notifications + * batch per microtask, so N same-tick mutations produce one notification per + * touched key. */ export class SlotCore { private records = new Map<string, SlotRecord>() @@ -491,6 +495,7 @@ export class SlotCore { const root = this.record('root') root.spec = { kind: 'single', scope: 'root' } root.declaredBy = '(built-in)' + root.declarationEpoch = 1 } /** @@ -631,12 +636,22 @@ export class SlotCore { rec.entries = next this.markDirty(options.name, rec) if (options.children) { + const declarations: [key: string, record: SlotRecord][] = [] for (const [childKey, childSpec] of Object.entries(options.children)) { const childRec = this.record(childKey) childRec.spec = childSpec childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}` + childRec.declarationEpoch += 1 + declarations.push([childKey, childRec]) + } + // Synchronous listeners may register into or try to redeclare a sibling; + // publish only after the whole children table owns its declarations. + for (const [childKey, childRec] of declarations) { this.markDirty(childKey, childRec) } + for (const [, childRec] of declarations) { + this.notifyDeclaration(childRec) + } } return () => { if (!rec.entries.includes(entry)) return @@ -692,6 +707,16 @@ export class SlotCore { return this.records.get(key)?.spec } + /** + * Read the declaration lifetime of a key. Entry additions and removals do + * not change it; declaration creation and collapse each advance it. + * @param key - slot key. + * @returns monotonic epoch (0 before the first declaration). + */ + declarationEpoch(key: string): number { + return this.records.get(key)?.declarationEpoch ?? 0 + } + /** * Subscribe to registration changes for a key (microtask-batched). * Subscribing ahead of declaration is allowed; the declaration notifies. @@ -705,6 +730,22 @@ export class SlotCore { return () => { rec.listeners.delete(fn) } } + /** + * Subscribe to declaration lifetime boundaries for a key. Notifications + * are synchronous so declaration teardown finishes before a subsequent + * same-tick registration can observe stale resources. Ordinary entry + * mutations do not notify this surface. A children table commits every + * sibling declaration before its first notification. + * @param key - slot key. + * @param fn - declaration or collapse callback. + * @returns unsubscribe. + */ + subscribeDeclaration(key: string, fn: () => void): () => void { + const rec = this.record(key) + rec.declarationListeners.add(fn) + return () => { rec.declarationListeners.delete(fn) } + } + /** * Monotonic version for a key, bumped synchronously per mutation so a * uSES getSnapshot read is never stale when its batched notification lands. @@ -746,8 +787,10 @@ export class SlotCore { const doomed = childRec.entries childRec.spec = undefined childRec.declaredBy = undefined + childRec.declarationEpoch += 1 childRec.entries = NO_ENTRIES this.markDirty(childKey, childRec) + this.notifyDeclaration(childRec) for (const dead of doomed) this.releaseEntry(dead) } } @@ -755,7 +798,15 @@ export class SlotCore { private record(key: string): SlotRecord { let rec = this.records.get(key) if (!rec) { - rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() } + rec = { + spec: undefined, + declaredBy: undefined, + declarationEpoch: 0, + entries: NO_ENTRIES, + version: 0, + listeners: new Set(), + declarationListeners: new Set(), + } this.records.set(key, rec) } return rec @@ -771,6 +822,10 @@ export class SlotCore { } } + private notifyDeclaration(rec: SlotRecord): void { + for (const fn of [...rec.declarationListeners]) fn() + } + private flush(): void { // Reset before iterating so a mutation from inside a listener re-schedules. this.flushScheduled = false diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.spec.ts index 8ed6b56498..bd0170d223 100644 --- a/packages/client/ui-slots/tests/core.spec.ts +++ b/packages/client/ui-slots/tests/core.spec.ts @@ -231,6 +231,22 @@ describe('store scope pinning', () => { }) describe('subscription surface', () => { + it('tracks declaration epochs separately from ordinary entry mutations', () => { + const core = new SlotCore() + expect(core.declarationEpoch('root')).toBe(1) + expect(core.declarationEpoch('test.list')).toBe(0) + const disposeFrame = mountFrame(core) + const declared = core.declarationEpoch('test.list') + expect(declared).toBe(1) + const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp) + disposeEntry() + expect(core.declarationEpoch('test.list')).toBe(declared) + disposeFrame() + expect(core.declarationEpoch('test.list')).toBe(declared + 1) + mountFrame(core) + expect(core.declarationEpoch('test.list')).toBe(declared + 2) + }) + it('entries() returns a stable cached reference between mutations', () => { const core = new SlotCore() mountFrame(core) @@ -267,6 +283,44 @@ describe('subscription surface', () => { expect(fn).toHaveBeenCalledTimes(1) }) + it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => { + const core = new SlotCore() + const fn = vi.fn() + const unsubscribe = core.subscribeDeclaration('test.list', fn) + const disposeFrame = mountFrame(core) + expect(fn).toHaveBeenCalledTimes(1) + core.register({ name: 'test.list', id: 'ordinary' }, Comp) + expect(fn).toHaveBeenCalledTimes(1) + disposeFrame() + expect(fn).toHaveBeenCalledTimes(2) + unsubscribe() + mountFrame(core) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('commits sibling declarations before notifying declaration subscribers', () => { + const core = new SlotCore() + let duplicateDeclaration: unknown + const unsubscribe = core.subscribeDeclaration('test.single', () => { + core.register({ name: 'test.list', id: 'from-listener' }, Comp) + try { + core.register({ + name: 'test.single', + children: { 'test.list': { kind: 'list', scope: 'root' } }, + }, Comp as never) + } catch (error) { + duplicateDeclaration = error + } + }) + + const disposeFrame = mountFrame(core) + expect(core.entries('test.list')).toHaveLength(1) + expect(String(duplicateDeclaration)).toContain('already declared') + unsubscribe() + disposeFrame() + expect(core.specDynamic('test.list')).toBeUndefined() + }) + it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => { const core = new SlotCore() mountFrame(core) diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts deleted file mode 100644 index 6e7b983852..0000000000 --- a/packages/client/ui-slots/tests/deferred.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -// deferRegistration lifecycle: declaration-aware registration, HMR -// re-registration, and — the failure contract — no subscription survives a -// construction that throws synchronously (an already-occupied single slot). -import { describe, expect, it, vi } from 'vitest' -import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' - -// Shares the merges declared by core.spec.ts (same program); reuse its keys. -const HOLE = 'test.single' as const - -function declared(): SlotCore { - const core = new SlotCore() - core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) - return core -} - -describe('deferRegistration', () => { - it('registers immediately under an existing declaration and disposes cleanly', () => { - const core = declared() - const component = (): null => null - const handle = deferRegistration(core, HOLE, component, () => - core.register({ name: HOLE } as never, component as never)) - expect(core.entries(HOLE)).toHaveLength(1) - handle.dispose() - expect(core.entries(HOLE)).toHaveLength(0) - }) - - it('hands a late registration failure to onFailure after unsubscribing itself', async () => { - const core = new SlotCore() - const component = (): null => null - const foreign = (): null => null - const failures: unknown[] = [] - // Nothing is declared yet: the deferral just subscribes and waits. - const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) - deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) }) - // The declaration lands with a foreign occupant racing in first: the - // deferral's flush-time attempt fails, unsubscribes itself, and reports - // through onFailure instead of throwing out of the flush. - core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) - const disposeForeign = core.register({ name: HOLE } as never, foreign as never) - await Promise.resolve() - expect(failures.map(String).join('')).toContain('already has a registration') - // Unsubscribed: freeing the hole must not resurrect the loser. - disposeForeign() - await Promise.resolve() - expect(core.entries(HOLE)).toHaveLength(0) - }) - - it('drops its subscription when the immediate registration throws', async () => { - const core = declared() - const foreign = (): null => null - const disposeForeign = core.register({ name: HOLE } as never, foreign as never) - const component = (): null => null - const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) - // The single hole is occupied: the immediate attempt throws out of the - // constructor, and the caller never receives a handle to dispose. - expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/) - expect(register).toHaveBeenCalledOnce() - // The subscription rolled back with it: freeing the hole flushes a - // notification that must not resurrect the failed registration. - disposeForeign() - await Promise.resolve() - expect(register).toHaveBeenCalledOnce() - expect(core.entries(HOLE)).toHaveLength(0) - }) -}) - -describe('deferGroupRegistration', () => { - const HOLES = ['test.single', 'test.grandchild'] as const - - function declaredPair(): SlotCore { - const core = new SlotCore() - core.register({ - name: 'root', - children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), - } as never, (() => null) as never) - return core - } - - it('registers the whole group and disposes it as a unit', () => { - const core = declaredPair() - const component = (): null => null - const group = deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never)) - for (const name of HOLES) expect(core.entries(name)).toHaveLength(1) - group.dispose() - for (const name of HOLES) expect(core.entries(name)).toHaveLength(0) - }) - - it('rolls the group back when construction fails partway', () => { - const core = declaredPair() - const component = (): null => null - core.register({ name: HOLES[1] } as never, (() => null) as never) - expect(() => deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never))).toThrow(/already has a registration/) - // The first hole's registration and subscription rolled back with it. - expect(core.entries(HOLES[0])).toHaveLength(0) - }) - - it('rolls the group back and re-raises loudly on a late conflict', async () => { - const core = new SlotCore() - const component = (): null => null - const failures: unknown[] = [] - const onLoud = (reason: unknown): void => { failures.push(reason) } - process.on('uncaughtException', onLoud) - try { - const group = deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never)) - // Declaration lands with a rival racing in ahead of the flush. - core.register({ - name: 'root', - children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), - } as never, (() => null) as never) - core.register({ name: HOLES[0] } as never, (() => null) as never) - core.register({ name: HOLES[1] } as never, (() => null) as never) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(failures.map(String).join('')).toContain('already has a registration') - // No partial occupancy from the group's owner survives. - for (const name of HOLES) { - expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0) - } - group.dispose() - } finally { - process.off('uncaughtException', onLoud) - } - }) -}) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index a519393a5a..8bb19b2ba2 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md README.md: cb210b219a8c66985eb4e1370468372eed9614b4 -README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c +README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2 diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 7b87fa1095..857e92d05a 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -6,9 +6,9 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 -one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 +one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。 -普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 +普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 `@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 child;pick 会插入字面文本 `@label `,codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。 @@ -18,7 +18,7 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) #### 模型看到的内容 -只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section;获准进入的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。 +只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section;已接收的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。 #### Token 影响 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开符合授权边界的取消按钮。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 9e6b120c1e..026b00196b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 31579dc258..95626cbaf1 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -36,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] +export const inject = ['slash', 'sessions', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -103,7 +103,8 @@ export function apply(ctx: ClientContext): void { sessions.setSubagentCatalogOpen(parentSessionId, open) }, }) - ctx.effect( + ctx.slots.inject( + 'conversation.session.header.actions', () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', @@ -111,15 +112,14 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: catalogActions, }, SubagentCatalogAction), - 'ui-subagent: lazy descendant catalog action', ) - ctx.effect( + ctx.slots.inject( + 'conversation.composer', () => ctx.slots.register({ name: 'conversation.composer', priority: -10, locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), - 'ui-subagent: read-only addressed composer', ) } diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index d2332cb3af..09221e1e94 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -75,7 +75,6 @@ async function provideSlotFaces(ctx: Context): Promise<void> { 'conversation.composer': { kind: 'chain', scope: 'session' }, }, } as never, () => null) - ctx.provide('conversation', {}) } /** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ @@ -113,7 +112,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) + expect(inject).toEqual(['slash', 'sessions', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index b6c02fcfb7..e0610d3379 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -56,7 +56,6 @@ function props( displayTitle: 'worker', running: true, blank: false, - waitingApproval: false, updatedAt: Date.now(), }, }, @@ -83,7 +82,6 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { displayTitle: id, running: false, blank: false, - waitingApproval: false, updatedAt, } } diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index ea7237402a..76bcbaf608 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md README.md: 88e21fe214ec806b101050949690283d811be36d -README.zh.md: 4ed45070234acb78a2e5edef52578b504ae53077 +README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50 diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 4ed4507023..ba781ba89a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -8,7 +8,7 @@ 滚动条重新绑定契约:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量另一个合法的目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。 -两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。 +两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。 ## 模型体验 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7046f3391b..1adad710cc 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -19,7 +19,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./styles/*": "./src/styles/*", + "./styles/*": "./lib/styles/*", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -56,9 +56,8 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/styles", + "lib/types/**/*.d.ts" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 133436c693..eb096412f5 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -254,16 +254,12 @@ export function apply(ctx: ClientContext): void { setTheme: (id) => { theme.setTheme(id) }, } } - ctx.effect(() => { - const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'appearance', - order: 10, - store, - locale: SETTINGS_NS, - inject: injected, - }, AppearanceRow)) - return () => { deferred.dispose() } - }, 'ui-theme: appearance settings row registration') + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + locale: SETTINGS_NS, + inject: injected, + }, AppearanceRow)) } diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 1bc83af0e9..08616753ce 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,3 +1,11 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-theme', ['lib/types/index.js', 'lib/types/invariant.js']) +const [lib, client] = clientBundle( + '@deepseek-ai/dsh-client-ui-theme', + ['lib/types/index.js', 'lib/types/invariant.js'], +) + +export default [{ + ...lib, + copy: [{ from: 'src/styles/*', to: 'lib/styles' }], +}, client] diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 36e56c4569..83f0b0b2e5 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11 -README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad +README.md: 2c737fe2d04df518e7d32d07aaede714a7a3566d +README.zh.md: 738d6cafa0c6d44f05fecec01d565da91ef433e8 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5d0ea3bbbb..2c737fe2d0 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred. +- **In-flight Time stays blank** — `partial` and `runningCalls` rows show their running state without a fabricated duration, so the Overview renders a start marker rather than inventing a live span. Record and timeline selection are local to Trajectory, with no anchor deep links. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 1bfff4c18e..738d6cafa0 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 @@ -14,4 +14,4 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助 ## 已知限制与暂缓事项 -- **进行中时,Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。 +- **进行中时,Time 保持空白**:`partial` 与 `runningCalls` 行会显示运行状态,但不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度。记录选择与时间线选择位于 Trajectory 内部,不提供锚点深链接。 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 8da6559866..f56a9825ce 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -35,6 +35,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@tanstack/react-virtual": "^3.14.9", "diff": "^9.0.0" }, "peerDependencies": { @@ -42,7 +43,8 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -51,15 +53,15 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 69d9d620e3..b1d9be9930 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -13,6 +13,7 @@ } .tablePane { + position: relative; flex: 1; min-width: 0; overflow-x: hidden; @@ -21,6 +22,53 @@ container: trajectory-table / inline-size; } +.historyLoading { + position: sticky; + z-index: 5; + top: 0; + height: 0; + overflow: visible; + pointer-events: none; +} + +.historyLoadingBar { + display: flex; + width: 100%; + height: 30px; + align-items: center; + justify-content: center; + gap: 6px; + box-sizing: border-box; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxs-12); +} + +.historyLoadingSpinner { + width: 10px; + height: 10px; + box-sizing: border-box; + border: 1.5px solid var(--dsw-alias-border-l2); + border-top-color: var(--dsw-alias-state-business-primary); + border-radius: 50%; + animation: history-loading-spin 700ms linear infinite; +} + +.table:not([data-scroll-ready='true']) { + visibility: hidden; +} + +@keyframes history-loading-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .historyLoadingSpinner { + animation: none; + } +} + .table { --trajectory-turn-accent: color-mix( in srgb, @@ -79,7 +127,17 @@ white-space: nowrap; } -.table tbody tr:not([data-collapsed-summary]) { +.table tbody .virtualSpacer { + pointer-events: none; +} + +.table tbody .virtualSpacer td { + height: var(--trajectory-virtual-spacer-height); + padding: 0; + border: 0; +} + +.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) { cursor: default; outline: none; transition: @@ -91,7 +149,7 @@ opacity: 0.24; } -.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover { +.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -106,7 +164,7 @@ border-bottom: 0; } -.table tbody tr[data-request-only='true']:last-child td { +.table tbody tr[data-terminal-request-boundary='true'] td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; } @@ -620,6 +678,10 @@ white-space: nowrap; } +.toolCallOnly { + color: var(--dsw-alias-label-tertiary); +} + .table tbody tr[data-collapsed-summary='turn'] td, .table tbody tr[data-collapsed-summary='assistant'] td { height: 20px; @@ -936,6 +998,17 @@ overflow: auto; } +.summaryScrollRegion { + --dsh-scrollbar-thumb: transparent; + --dsh-scrollbar-thumb-hover: transparent; +} + +.summaryScrollRegion:hover, +.summaryScrollRegion:focus-within { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + .compactedSummary .markdownPayload { padding-right: 18px; } @@ -1023,7 +1096,6 @@ margin: 0; overflow: hidden; color: var(--dsw-alias-label-primary); - font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; } @@ -1033,7 +1105,6 @@ color: inherit; cursor: pointer; font: inherit; - font-variant-numeric: tabular-nums; user-select: text; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 6f37c852c5..9cef2dccfb 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import { IconChevronRightOutline14, IconSettingsOutline16, @@ -18,11 +19,19 @@ import type { import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' -import { formatElapsedSeconds } from './trajectory-record.ts' +import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts' +import { + groupTrajectoryVirtualRows, trajectoryVirtualRecordKey, +} from './trajectory-virtual-rows.ts' +import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 +const OLDER_LOAD_THRESHOLD_PX = 48 +const VIRTUALIZATION_THRESHOLD = 100 +const VIRTUAL_OVERSCAN_ROWS = 12 +const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 const KIND_LABEL: Record<TrajectoryCellKind, string> = { system: 'SYSTEM', @@ -117,6 +126,30 @@ interface TableRecord { collapsedSummaryKind?: 'turn' | 'assistant' } +interface VirtualRowStructure { + height: number + key: string +} + +function useStableVirtualRowStructure( + rows: readonly TrajectoryVirtualRow<TableRecord>[], +): readonly VirtualRowStructure[] { + const cache = useRef<{ + rows: readonly TrajectoryVirtualRow<TableRecord>[] + structure: readonly VirtualRowStructure[] + }>({ rows: [], structure: [] }) + if (cache.current.rows === rows) return cache.current.structure + const structure = cache.current.structure.length === rows.length + && rows.every((row, index) => { + const previous = cache.current.structure[index] + return previous?.key === row.key && previous.height === row.height + }) + ? cache.current.structure + : rows.map(row => ({ key: row.key, height: row.height })) + cache.current = { rows, structure } + return structure +} + type DetailTab = | 'system-prompt' | 'tools' @@ -150,9 +183,8 @@ interface ToolCallTextParts { interface SelectedRequest { turn: number | null - section: number - number: number group: string + seq?: number } interface DetailsResizeDrag { @@ -195,6 +227,16 @@ type RequestBoundaryStyle = CSSProperties & { '--request-boundary-offset': string } +type VirtualSpacerStyle = CSSProperties & { + '--trajectory-virtual-spacer-height': string +} + +interface OlderLoadAnchor { + readonly historyStartSeq: number | undefined + readonly scrollHeight: number + readonly scrollTop: number +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -228,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string { return `${day} ${time}` } +/** Whether a click lands on an active text selection and should keep it. */ +function clickSelectsText(target: Node): boolean { + const selection = window.getSelection() + return selection !== null + && !selection.isCollapsed + && selection.rangeCount > 0 + && selection.getRangeAt(0).intersectsNode(target) +} + function StartedAtValue({ timestamp }: { timestamp: number | null }) { const [showUnix, setShowUnix] = useState(false) if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd> @@ -238,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) { className={css.timestampToggle} title={showUnix ? 'Show local time' : 'Show Unix timestamp'} onClick={(event) => { - const selection = window.getSelection() - if ( - selection !== null - && !selection.isCollapsed - && selection.rangeCount > 0 - && selection.getRangeAt(0).intersectsNode(event.currentTarget) - ) return + if (clickSelectsText(event.currentTarget)) return setShowUnix(current => !current) }} > @@ -302,6 +347,8 @@ export interface TrajectoryTableProps { requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ turns: readonly TrajectoryTurnModel[] + /** In-flight cells whose content replaces the matching structural record index. */ + streamingCells?: readonly TrajectoryCellProps[] /** Record indexes emphasized by the active timeline focus. */ timelineFocusIndexes?: ReadonlySet<number> | null /** Record indexes retained by the active live search, or null without a query. */ @@ -312,16 +359,26 @@ export interface TrajectoryTableProps { onRecordSelect?: (index: number) => void /** One externally requested record selection; a new object repeats the request. */ recordSelection?: { readonly index: number } | null + /** One externally requested record focus without changing inspector selection. */ + recordFocus?: { readonly index: number } | null + /** Whether the initial history tail is still loading. */ + historyLoading?: boolean + /** First loaded raw event, used to preserve scroll position after prepending a page. */ + historyStartSeq?: number | undefined + /** Whether one older history page can be requested. */ + hasOlderRecords?: boolean + /** Load one older history page. */ + onLoadOlder?: () => Promise<boolean> /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ collapsedTurns: ReadonlySet<number> /** Toggle one turn between folded and expanded. */ onToggleTurn: (turn: number) => void - /** Assistant record indexes whose tool calls are folded. */ - collapsedAssistants: ReadonlySet<number> + /** Stable Assistant record ids whose tool calls are folded. */ + collapsedAssistants: ReadonlySet<string> /** Toggle tool calls under one assistant record. */ - onToggleAssistant: (index: number) => void + onToggleAssistant: (id: string) => void /** One-shot cross-view inspect: open and scroll to this call's record. */ inspectCallId?: string | null /** Acknowledge a consumed (or unresolvable) inspect request. */ @@ -492,7 +549,6 @@ function collapseTurnRecords( records: readonly TableRecord[], collapsedTurns: ReadonlySet<number>, ): TableRecord[] { - if (collapsedTurns.size === 0) return [...records] const recordsByTurn = new Map<number, TableRecord[]>() for (const record of records) { if (record.turn === null) continue @@ -550,15 +606,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string { function collapseAssistantRecords( records: readonly TableRecord[], - collapsedAssistants: ReadonlySet<number>, + collapsedAssistants: ReadonlySet<string>, ): TableRecord[] { - if (collapsedAssistants.size === 0) return [...records] const out: TableRecord[] = [] for (let i = 0; i < records.length; i++) { const record = records[i] if (record === undefined) continue out.push(record) - if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue + if ( + record.cell.kind !== 'message' + || !collapsedAssistants.has(trajectoryRecordId(record.cell)) + ) continue const calls: TableRecord[] = [] for (let j = i + 1; j < records.length; j++) { const candidate = records[j] @@ -1335,7 +1393,7 @@ function RequestTiming({ <dt>Started</dt> <StartedAtValue timestamp={anchor?.cell.startedAt ?? null} /> </div> - <div><dt>Duration</dt><dd>—</dd></div> + <div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div> </dl> ) } @@ -1519,7 +1577,12 @@ function OverviewSection({ <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} /> </button> </h3> - <div className={css.overviewPreview}>{children}</div> + <div + className={`${css.overviewPreview} ${css.summaryScrollRegion}`} + data-summary-scroll-region="" + > + {children} + </div> </section> ) } @@ -1533,11 +1596,17 @@ function OverviewSection({ export function TrajectoryTable({ requestNumbers: sessionRequestNumbers, turns, + streamingCells = [], timelineFocusIndexes = null, searchMatchIndexes = null, onSelectedIndexChange, onRecordSelect, recordSelection = null, + recordFocus = null, + historyLoading = false, + historyStartSeq, + hasOlderRecords = false, + onLoadOlder, onClearSelection, collapsedTurns, onToggleTurn, @@ -1546,7 +1615,7 @@ export function TrajectoryTable({ inspectCallId = null, onInspectApplied, }: TrajectoryTableProps) { - const [selectedIndex, setSelectedIndex] = useState<number | null>(null) + const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null) const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null) const [activeTab, setActiveTab] = useState<DetailTab>('overview') const [thinkingExpanded, setThinkingExpanded] = useState(false) @@ -1554,20 +1623,116 @@ export function TrajectoryTable({ const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null) const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null) const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null) + const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null) const tabHistory = useRef<Set<DetailTab>>(new Set(['overview'])) + const rootRef = useRef<HTMLDivElement>(null) + const tablePaneRef = useRef<HTMLDivElement>(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) + const [tableScrollReady, setTableScrollReady] = useState(false) + const pendingScrollRecordId = useRef<string | null>(null) + const loadingOlder = useRef(false) + const [olderLoading, setOlderLoading] = useState(false) + const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null) + const allRecords = useMemo(() => flattenRecords(turns), [turns]) + const streamingCellsByIndex = useMemo( + () => new Map(streamingCells.map(cell => [cell.index, cell])), + [streamingCells], + ) + const currentRecord = useCallback((record: TableRecord): TableRecord => { + const cell = streamingCellsByIndex.get(record.cell.index) + return cell === undefined ? record : { ...record, cell } + }, [streamingCellsByIndex]) + const selectedTemplate = useMemo(() => selectedRecordId === null + ? undefined + : allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId), + [allRecords, selectedRecordId]) + const selected = selectedTemplate === undefined + ? undefined + : currentRecord(selectedTemplate) + const selectedIndex = selected?.cell.index ?? null useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const allRecords = useMemo(() => flattenRecords(turns), [turns]) - const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) - const records = searchMatchIndexes === null - ? collapseAssistantRecords( - collapseTurnRecords(allRecords, collapsedTurns), - collapsedAssistants, - ) - : filterRecords(allRecords, searchMatchIndexes) - const requestBoundaryRuns = indexRequestBoundaryRuns(records) - const selected = allRecords.find(record => record.cell.index === selectedIndex) + const requestNumbers = useMemo( + () => indexRequestNumbers(allRecords, sessionRequestNumbers), + [allRecords, sessionRequestNumbers], + ) + const records = useMemo(() => { + if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) + const turnRecords = collapsedTurns.size === 0 + ? allRecords + : collapseTurnRecords(allRecords, collapsedTurns) + return collapsedAssistants.size === 0 + ? turnRecords + : collapseAssistantRecords(turnRecords, collapsedAssistants) + }, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes]) + const projectedVirtualRows = useMemo( + () => groupTrajectoryVirtualRows(records), + [records], + ) + const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows) + const virtualizationEnabled = hasOlderRecords + || records.length > VIRTUALIZATION_THRESHOLD + const estimateVirtualRowSize = useCallback( + (index: number) => virtualRowStructure[index]?.height ?? 30, + [virtualRowStructure], + ) + const getVirtualRowKey = useCallback( + (index: number) => virtualRowStructure[index]?.key ?? index, + [virtualRowStructure], + ) + const getTableScrollElement = useCallback(() => tablePaneRef.current, []) + const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({ + count: virtualizationEnabled ? virtualRowStructure.length : 0, + enabled: virtualizationEnabled, + estimateSize: estimateVirtualRowSize, + getItemKey: getVirtualRowKey, + getScrollElement: getTableScrollElement, + initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX }, + anchorTo: 'end', + overscan: VIRTUAL_OVERSCAN_ROWS, + scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX, + }) + const virtualIndexByRecordId = useMemo(() => { + const indexes = new Map<string, number>() + for (const [virtualIndex, row] of projectedVirtualRows.entries()) { + for (const entry of row.entries) { + if (entry.record.collapsedSummary === undefined) { + indexes.set(trajectoryRecordId(entry.record.cell), virtualIndex) + } + } + } + return indexes + }, [projectedVirtualRows]) + const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : [] + const virtualTop = virtualItems[0]?.start ?? 0 + const virtualBottom = virtualItems.length === 0 + ? 0 + : Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0)) + const renderedRecords = virtualizationEnabled + ? virtualItems.flatMap((item) => { + const row = projectedVirtualRows[item.index] + if (row === undefined) return [] + return row.entries.map((entry, entryIndex) => ({ + record: currentRecord(entry.record), + position: entry.logicalIndex, + terminalRequestBoundary: + entry.record.cell.requestOnly === true + && row.entries.at(-1)?.record.cell.requestOnly === true + && entryIndex === row.entries.length - 1, + })) + }) + : records.map((record, position) => ({ + record: currentRecord(record), + position, + terminalRequestBoundary: + record.cell.requestOnly === true && position === records.length - 1, + })) + const requestBoundaryRuns = useMemo( + () => indexRequestBoundaryRuns(records), + [records], + ) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail : undefined @@ -1576,20 +1741,25 @@ export function TrajectoryTable({ : undefined const promptSelected = selectedPrompt !== undefined const selectedState = selected === undefined ? undefined : stateOf(selected) - const selectedRequestRecords = selectedRequest === null + const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null ? [] : allRecords.filter(record => record.turn === selectedRequest.turn - && record.section === selectedRequest.section && record.group === selectedRequest.group, - ) + ), [allRecords, selectedRequest]) + const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord) const selectedRequestAssistant = selectedRequestRecords.find( record => record.cell.kind === 'message', ) const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] + const selectedRequestNumber = selectedRequest === null + ? undefined + : requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group)) const selectedRequestInfo = selectedRequest === null ? undefined - : sessionRequestNumbers?.find(request => request.number === selectedRequest.number) + : sessionRequestNumbers?.find(request => selectedRequest.seq === undefined + ? request.turn === selectedRequest.turn && request.group === selectedRequest.group + : request.seq === selectedRequest.seq) const selectedRequestState: RecordState | undefined = selectedRequest === null ? undefined : selectedRequestInfo?.status @@ -1605,9 +1775,12 @@ export function TrajectoryTable({ const selectedRequestSubtoolCalls = selectedRequestRecords.filter( record => record.cell.kind === 'subtool', ).length - const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined + const selectedRequestResultTemplate = selectedRequestInfo?.resultSeq === undefined ? selectedRequestAssistant : allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq) + const selectedRequestResult = selectedRequestResultTemplate === undefined + ? undefined + : currentRecord(selectedRequestResultTemplate) const selectedRequestUsage = selectedRequestInfo?.usage ?? ( selectedRequestAssistant === undefined ? undefined @@ -1633,7 +1806,9 @@ export function TrajectoryTable({ selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage const selectedRequestOptions = selectedRequestInfo?.requestConfig const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn - const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section + const activeSection = selectedRequest === null + ? selected?.section + : selectedRequestRecords[0]?.section const selectedTabs = selectedRequest !== null ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : selected === undefined ? [] : detailTabs(selected) @@ -1645,13 +1820,17 @@ export function TrajectoryTable({ const selectedAssistantRequest = selected?.cell.kind === 'message' ? requestNumbers.get(requestKey(selected.turn, selected.group)) : undefined + const selectedAssistantRequestInfo = selectedAssistantRequest === undefined + ? undefined + : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest) const selectedAssistantRequestTarget: SelectedRequest | undefined = selected !== undefined && selectedAssistantRequest !== undefined ? { turn: selected.turn, - section: selected.section, - number: selectedAssistantRequest, group: selected.group, + ...(selectedAssistantRequestInfo?.seq === undefined + ? {} + : { seq: selectedAssistantRequestInfo.seq }), } : undefined const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined @@ -1670,7 +1849,7 @@ export function TrajectoryTable({ } const clearInspectorSelection = () => { - setSelectedIndex(null) + setSelectedRecordId(null) setSelectedRequest(null) } @@ -1683,7 +1862,7 @@ export function TrajectoryTable({ const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) - setSelectedIndex(index) + setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell)) if (record === undefined) return const tabs = detailTabs(record) const available = new Set(tabs.map(tab => tab.id)) @@ -1697,13 +1876,25 @@ export function TrajectoryTable({ ) return appliedRecordSelection.current = recordSelection selectRecord(recordSelection.index) - }, [recordSelection, selectRecord]) + const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index) + pendingScrollRecordId.current = record === undefined + ? null + : trajectoryRecordId(record.cell) + }, [allRecords, recordSelection, selectRecord]) + useEffect(() => { + if (recordFocus === null || appliedRecordFocus.current === recordFocus) return + appliedRecordFocus.current = recordFocus + const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index) + pendingScrollRecordId.current = record === undefined + ? null + : trajectoryRecordId(record.cell) + }, [allRecords, recordFocus]) const selectRequest = ( request: SelectedRequest, tab: 'overview' | 'timing' = 'overview', ) => { - setSelectedIndex(null) + setSelectedRecordId(null) setSelectedRequest(request) activateTab(tab) } @@ -1716,12 +1907,13 @@ export function TrajectoryTable({ const candidate = allRecords[i] if (candidate === undefined || candidate.turn !== target.turn) break if (candidate.cell.kind !== 'message') continue - if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index) + const assistantId = trajectoryRecordId(candidate.cell) + if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId) break } } setSelectedRequest(null) - setSelectedIndex(target.cell.index) + setSelectedRecordId(trajectoryRecordId(target.cell)) activateTab('overview') } @@ -1734,11 +1926,6 @@ export function TrajectoryTable({ // open its summary, and remember the row to scroll once the un-collapsed // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. - const rootRef = useRef<HTMLDivElement>(null) - const tablePaneRef = useRef<HTMLDivElement>(null) - const followsTableTail = useRef(false) - const tableScrollInitialized = useRef(false) - const pendingScrollIndex = useRef<number | null>(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary useEffect(() => { @@ -1746,61 +1933,213 @@ export function TrajectoryTable({ const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId) if (target === undefined) return openRecordSummaryRef.current(target) - pendingScrollIndex.current = target.cell.index + pendingScrollRecordId.current = trajectoryRecordId(target.cell) onInspectApplied?.() }, [inspectCallId, turns, onInspectApplied]) useEffect(() => { - const index = pendingScrollIndex.current - if (index === null) return - const row = rootRef.current - ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`) - if (row === undefined || row === null) return - pendingScrollIndex.current = null + const id = pendingScrollRecordId.current + if (id === null) return + const position = records.findIndex(record => + trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined) + if (position === -1) return + if (virtualizationEnabled) { + const virtualIndex = virtualIndexByRecordId.get(id) + if (virtualIndex === undefined) return + pendingScrollRecordId.current = null + followsTableTail.current = false + rowVirtualizer.scrollToIndex(virtualIndex, { behavior: 'smooth', align: 'center' }) + return + } + pendingScrollRecordId.current = null + followsTableTail.current = false + const recordIndex = records[position]?.cell.index + const row = recordIndex === undefined + ? null + : rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`) /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ - if (typeof row.scrollIntoView === 'function') { + if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } - }) + }, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled]) + useEffect(() => { + if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return + const focusedPositions = records.flatMap((record, position) => + record.collapsedSummary === undefined + && record.cell.requestOnly !== true + && timelineFocusIndexes.has(record.cell.index) + ? [position] + : []) + const first = focusedPositions.at(0) + const last = focusedPositions.at(-1) + if (first === undefined || last === undefined) return + if (!virtualizationEnabled) { + const ledger = rootRef.current + if (ledger === null) return + const focusedRows = [ + ...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'), + ] + const firstRow = focusedRows.at(0) + const lastRow = focusedRows.at(-1) + if (firstRow === undefined || lastRow === undefined) return + const focusHeight = + lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top + const target = focusHeight > ledger.clientHeight + ? firstRow + : focusedRows[Math.floor((focusedRows.length - 1) / 2)] + /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ + if (target !== undefined && typeof target.scrollIntoView === 'function') { + followsTableTail.current = false + target.scrollIntoView({ + behavior: 'smooth', + block: focusHeight > ledger.clientHeight ? 'start' : 'center', + }) + } + return + } + const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => { + const record = records[position] + if (record === undefined) return [] + const virtualIndex = virtualIndexByRecordId.get(trajectoryRecordId(record.cell)) + return virtualIndex === undefined ? [] : [virtualIndex] + }))].sort((left, right) => left - right) + const firstVirtual = focusedVirtualIndexes.at(0) + const lastVirtual = focusedVirtualIndexes.at(-1) + if (firstVirtual === undefined || lastVirtual === undefined) return + const paneHeight = tablePaneRef.current?.clientHeight ?? 0 + const focusHeight = projectedVirtualRows + .slice(firstVirtual, lastVirtual + 1) + .reduce((height, row) => height + row.height, 0) + followsTableTail.current = false + rowVirtualizer.scrollToIndex( + focusHeight > paneHeight + ? firstVirtual + : focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)] + ?? firstVirtual, + { + behavior: 'smooth', + align: focusHeight > paneHeight ? 'start' : 'center', + }, + ) + }, [ + projectedVirtualRows, + records, + rowVirtualizer, + timelineFocusIndexes, + virtualIndexByRecordId, + virtualizationEnabled, + ]) + const requestOlder = useCallback((pane: HTMLDivElement) => { + if ( + !hasOlderRecords + || onLoadOlder === undefined + || loadingOlder.current + || pane.scrollTop > OLDER_LOAD_THRESHOLD_PX + ) return + loadingOlder.current = true + setOlderLoading(true) + olderLoadAnchor.current = { + historyStartSeq, + scrollHeight: pane.scrollHeight, + scrollTop: pane.scrollTop, + } + void onLoadOlder().then((advanced) => { + if (!advanced) olderLoadAnchor.current = null + }).finally(() => { + loadingOlder.current = false + setOlderLoading(false) + }) + }, [hasOlderRecords, historyStartSeq, onLoadOlder]) useLayoutEffect(() => { const pane = tablePaneRef.current if (pane === null) return - if (!tableScrollInitialized.current) { - tableScrollInitialized.current = true - followsTableTail.current = - pane.scrollHeight - pane.clientHeight - pane.scrollTop - <= BOTTOM_FOLLOW_THRESHOLD_PX + const anchor = olderLoadAnchor.current + if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) { + if (!virtualizationEnabled) { + pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight + } + olderLoadAnchor.current = null + followsTableTail.current = false return } - if (followsTableTail.current) pane.scrollTop = pane.scrollHeight - }, [turns]) + if (!tableScrollInitialized.current) { + if (historyLoading) return + tableScrollInitialized.current = true + followsTableTail.current = true + if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) + else pane.scrollTop = pane.scrollHeight + setTableScrollReady(true) + return + } + if (!followsTableTail.current) return + if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) + else pane.scrollTop = pane.scrollHeight + }, [ + historyLoading, + historyStartSeq, + rowVirtualizer, + virtualRowStructure, + virtualizationEnabled, + ]) + + const loadingLabel = olderLoading + ? 'Loading earlier history…' + : 'Loading trajectory…' + const showLoading = historyLoading || olderLoading || !tableScrollReady return ( <div ref={rootRef} className={css.split} style={splitStyle}> <div ref={tablePaneRef} className={css.tablePane} + data-trajectory-scroll="" onScroll={(event) => { const pane = event.currentTarget followsTableTail.current = pane.scrollHeight - pane.clientHeight - pane.scrollTop <= BOTTOM_FOLLOW_THRESHOLD_PX + requestOlder(pane) }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} > - <table className={css.table}> + {showLoading && ( + <div className={css.historyLoading} role="status" aria-live="polite"> + <span className={css.historyLoadingBar}> + <span className={css.historyLoadingSpinner} aria-hidden="true" /> + {loadingLabel} + </span> + </div> + )} + <table + className={css.table} + data-scroll-ready={tableScrollReady || undefined} + aria-rowcount={records.length} + > <colgroup> <col className={css.eventColumn} /> <col className={css.contentColumn} /> </colgroup> <tbody> - {records.map((record) => { + {virtualTop > 0 && ( + <tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true"> + <td + colSpan={2} + style={{ + '--trajectory-virtual-spacer-height': `${virtualTop}px`, + } as VirtualSpacerStyle} + /> + </tr> + )} + {renderedRecords.map(({ record, position, terminalRequestBoundary }) => { const displayText = recordDisplayText(record.cell) + const toolCallOnly = isToolCallOnly(record.cell) const toolCallText = toolCallTextParts(record.cell.kind, displayText) - const listDisplayText = toolCallText === undefined - ? displayText - : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') + const listDisplayText = toolCallOnly + ? '(tool call only)' + : toolCallText === undefined + ? displayText + : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') const isCollapsedSummary = record.collapsedSummary !== undefined const isRequestOnly = record.cell.requestOnly === true const isInitialSystem = record.cell.kind === 'system' @@ -1824,15 +2163,15 @@ export function TrajectoryTable({ : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` const requestSelected = request !== undefined && selectedRequest?.turn === record.turn - && selectedRequest.section === record.section - && selectedRequest.number === request + && selectedRequest.group === record.group const sectionActive = record.turn === null ? activeSection === record.section : activeTurn === record.turn return ( <tr - key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`} + key={trajectoryVirtualRecordKey(record)} tabIndex={isRequestOnly ? -1 : 0} + aria-rowindex={position + 1} aria-label={isCollapsedSummary ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}` : isRequestOnly @@ -1840,10 +2179,13 @@ export function TrajectoryTable({ : `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`} aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index} data-kind={record.cell.kind} + data-trajectory-row-key={trajectoryVirtualRecordKey(record)} + data-virtual-position={virtualizationEnabled ? position : undefined} data-record-index={!isCollapsedSummary && !isRequestOnly ? record.cell.index : undefined} data-request-only={isRequestOnly || undefined} + data-terminal-request-boundary={terminalRequestBoundary || undefined} data-group-start={record.groupStart || undefined} data-turn-start={record.turnStart || undefined} data-error={record.cell.isError || undefined} @@ -1860,7 +2202,7 @@ export function TrajectoryTable({ ? () => { if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { onToggleTurn(record.turn) - } else onToggleAssistant(record.cell.index) + } else onToggleAssistant(trajectoryRecordId(record.cell)) } : () => { selectRecord(record.cell.index) }} onDoubleClick={(event) => { @@ -1875,7 +2217,7 @@ export function TrajectoryTable({ && assistantToolCalls(allRecords, record.cell.index).length > 0 ) { event.preventDefault() - onToggleAssistant(record.cell.index) + onToggleAssistant(trajectoryRecordId(record.cell)) return } if (!record.turnStart) return @@ -1894,7 +2236,7 @@ export function TrajectoryTable({ if (isCollapsedSummary) { if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { onToggleTurn(record.turn) - } else onToggleAssistant(record.cell.index) + } else onToggleAssistant(trajectoryRecordId(record.cell)) return } selectRecord(record.cell.index) @@ -1917,9 +2259,8 @@ export function TrajectoryTable({ event.stopPropagation() selectRequest({ turn: record.turn, - section: record.section, - number: request, group: record.group, + ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), }) }} onDoubleClick={(event) => { event.stopPropagation() }} @@ -2013,8 +2354,8 @@ export function TrajectoryTable({ : `${listDisplayText} → ${record.cell.result}`} > <span className={record.cell.result === undefined ? undefined : css.resultRequest}> - {isToolCallOnly(record.cell) - ? null + {toolCallOnly + ? <span className={css.toolCallOnly}>(tool call only)</span> : toolCallText === undefined ? listDisplayText || '—' : ( @@ -2047,6 +2388,16 @@ export function TrajectoryTable({ </tr> ) })} + {virtualBottom > 0 && ( + <tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true"> + <td + colSpan={2} + style={{ + '--trajectory-virtual-spacer-height': `${virtualBottom}px`, + } as VirtualSpacerStyle} + /> + </tr> + )} </tbody> </table> </div> @@ -2141,7 +2492,7 @@ export function TrajectoryTable({ <> <span className={css.requestDetailsDot} aria-hidden="true" /> <span className={css.requestDetailsName}> - Request #{selectedRequest.number} + Request #{selectedRequestNumber ?? '—'} </span> <span className={css.detailsLocation}> {selectedRequestInfo?.purpose === 'compaction' @@ -2220,7 +2571,10 @@ export function TrajectoryTable({ && selectedRequestState !== undefined && activeTab === 'overview' && ( <> - <dl className={css.overview}> + <dl + className={`${css.overview} ${css.summaryScrollRegion}`} + data-summary-scroll-region="" + > <div> <dt>Status</dt> <dd className={selectedRequestState === 'error' ? css.error : undefined}> @@ -2371,7 +2725,10 @@ export function TrajectoryTable({ && selectedState !== undefined && activeTab === 'overview' && ( <> - <dl className={css.overview}> + <dl + className={`${css.overview} ${css.summaryScrollRegion}`} + data-summary-scroll-region="" + > <div> <dt>Status</dt> <dd className={selectedState === 'error' ? css.error : undefined}> @@ -2388,7 +2745,10 @@ export function TrajectoryTable({ </div> </dl> {selected.cell.outputDetail !== undefined && ( - <div className={css.compactedSummary}> + <div + className={`${css.compactedSummary} ${css.summaryScrollRegion}`} + data-summary-scroll-region="" + > <MarkdownRecordContent record={selected} rendered @@ -2406,7 +2766,10 @@ export function TrajectoryTable({ && selectedState !== undefined && activeTab === 'overview' && ( <> - <dl className={css.overview}> + <dl + className={`${css.overview} ${css.summaryScrollRegion}`} + data-summary-scroll-region="" + > {selected.cell.messageSource !== undefined && ( <div> <dt>Origin</dt> @@ -2441,7 +2804,7 @@ export function TrajectoryTable({ selectRequest(selectedAssistantRequestTarget) }} > - <span>Request #{selectedAssistantRequestTarget.number}</span> + <span>Request #{selectedAssistantRequest ?? '—'}</span> <IconChevronRightOutline14 className={css.overviewHierarchyJumpIconTight} size={11} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 4d548e64d8..4d51548ff3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -61,6 +61,46 @@ cursor: grabbing; } +.earlierHistory { + position: absolute; + z-index: 5; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 28px; + align-items: center; + justify-content: flex-start; + appearance: none; + box-sizing: border-box; + padding-left: 3px; + border: 0; + outline: none; + background: linear-gradient( + to right, + var(--dsw-alias-bg-layer-2) 0, + var(--dsw-alias-bg-layer-2) 38%, + transparent 100% + ); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + line-height: 1; + opacity: 0.72; + cursor: pointer; +} + +.earlierHistory:hover { + opacity: 1; +} + +.earlierHistory[aria-disabled='true'] { + cursor: default; +} + +.earlierHistory:focus-visible { + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); +} + .empty { position: absolute; top: 50%; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 87d7cdcd34..1cf92c8d78 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] mode: TrajectoryTimelineMode range: TrajectoryTimeRange | null + /** Whether the loaded timeline omits an earlier history prefix. */ + hasEarlierRecords?: boolean + /** Load one earlier history page from the truncation control. */ + onLoadEarlier?: () => Promise<boolean> selectedIndex?: number | null /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet<number> | null @@ -191,11 +195,49 @@ function LaneLabels() { ) } +function EarlierHistoryBoundary({ + loading, + onHover, + onLoad, +}: { + loading: boolean + onHover: () => void + onLoad: (() => void) | undefined +}) { + return ( + <Tooltip + label={loading ? 'Loading earlier history…' : 'Click to load earlier history'} + side="right" + delayMs={TIMELINE_TOOLTIP_DELAY_MS} + > + <button + type="button" + className={css.earlierHistory} + data-earlier-history + data-loading={loading || undefined} + aria-label={loading ? 'Loading earlier history' : 'Load earlier history'} + aria-disabled={loading || onLoad === undefined} + onClick={onLoad} + onPointerEnter={(event) => { + event.stopPropagation() + onHover() + }} + onPointerMove={(event) => { event.stopPropagation() }} + onPointerDown={(event) => { event.stopPropagation() }} + > + … + </button> + </Tooltip> + ) +} + /** Overview renderer with drag ranges, click-sized focus, and Escape reset. */ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ turns, mode, range, + hasEarlierRecords = false, + onLoadEarlier, selectedIndex = null, searchMatchIndexes = null, onRangeChange, @@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const trackRef = useRef<HTMLDivElement | null>(null) const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null) const [hover, setHover] = useState<HoverPoint | null>(null) + const [loadingEarlier, setLoadingEarlier] = useState(false) const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null) const [animateViewport, setAnimateViewport] = useState(false) @@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const showsEarlierBoundary = hasEarlierRecords + && model !== null + && domainStart === model.start + const loadEarlier = onLoadEarlier === undefined || loadingEarlier + ? undefined + : () => { + setLoadingEarlier(true) + void onLoadEarlier().finally(() => { setLoadingEarlier(false) }) + } const projectedDomainStyle = model === null ? undefined : { @@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ <LaneLabels /> <div className={css.track}> <span className={css.empty}>No timing data</span> + {hasEarlierRecords && ( + <EarlierHistoryBoundary + loading={loadingEarlier} + onHover={() => { setHover(null) }} + onLoad={loadEarlier} + /> + )} </div> </div> </section> @@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ event.preventDefault() }} > + {showsEarlierBoundary && ( + <EarlierHistoryBoundary + loading={loadingEarlier} + onHover={() => { setHover(null) }} + onLoad={loadEarlier} + /> + )} {hover !== null && hover.recordIndex === null && draft === null && ( <div className={css.hoverLine} @@ -609,7 +675,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ .map((span) => { const left = (span.start - model.start) / fullDuration const width = (span.end - span.start) / fullDuration - const widthPercent = Math.max(width * 100, 0.35) + const widthPercent = width * 100 const detail = detailByIndex.get(span.index) const ttftMs = detail?.ttftMs const decodingMs = detail?.decodingMs @@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ style={{ '--trajectory-span-left': `${left * 100}%`, '--trajectory-span-width': `${widthPercent}%`, - '--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`, + '--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`, '--trajectory-span-lane': span.lane, ...(ttftFraction === null ? {} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 9ba75abaac..81b4f9d7f0 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantMessageNode, ConversationContext, + AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, SessionHistoryFace, SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' import { @@ -17,15 +17,51 @@ import { } from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { TrajectoryTimeline } from './TrajectoryTimeline.tsx' -import { deriveTrajectoryLayout } from './layout.ts' +import { + appendTrajectoryPartialLayout, deriveTrajectoryLayout, + type TrajectoryTurnModel, +} from './layout.ts' import { trajectoryTimelineFocusIndexes, type TrajectoryTimelineMode, type TrajectoryTimeRange, } from './timeline.ts' +import { trajectoryRecordId } from './trajectory-record.ts' import css from './views.module.css' -const EMPTY_IDS: ReadonlySet<number> = new Set() +const EMPTY_TURN_IDS: ReadonlySet<number> = new Set() +const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set() + +function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number { + let last = 0 + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) last = Math.max(last, cell.index) + } + } + return last +} + +function timelineBlock(block: AssistantBlock): AssistantBlock { + switch (block.kind) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { + kind: 'tool-call', + callId: block.callId, + name: block.name, + argsRaw: '', + } + case 'other': return { kind: 'other', block: null } + } +} + +function partialStructureSignature(partial: ConversationSnapshot['partial']): string { + if (partial === null) return '' + return partial.blocks.map(block => block.kind === 'tool-call' + ? `${block.kind}:${block.callId}:${block.name}` + : block.kind).join('\u0000') +} /** Session-history paging needed by the event-complete trajectory view. */ export interface TrajectoryViewInjected { @@ -33,7 +69,8 @@ export interface TrajectoryViewInjected { history: SessionHistoryFace duration: SnapshotStore<boolean> } - loadAllHistory: (signal: AbortSignal) => Promise<void> + loadHistoryTail: (signal: AbortSignal) => Promise<void> + loadOlderHistory: (signal: AbortSignal) => Promise<boolean> setActualDuration: (actualDuration: boolean) => void } @@ -137,14 +174,23 @@ function searchMatches( return matches } +function mergeSearchMatches( + finalized: ReadonlySet<number> | null, + partial: ReadonlySet<number> | null, +): ReadonlySet<number> | null { + if (finalized === null || partial === null) return null + return new Set([...finalized, ...partial]) +} + export function TrajectoryView({ - useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone, + useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + inspect, onInspectDone, }: ConvViewProps & InjectFace<TrajectoryViewInjected>) { - const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS) + const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = - useState<ReadonlySet<number>>(EMPTY_IDS) + useState<ReadonlySet<string>>(EMPTY_RECORD_IDS) const [timelineSelection, setTimelineSelection] = useState<{ - branchId: number + branchKey: string range: TrajectoryTimeRange } | null>(null) const actualDuration = useDuration(value => value) @@ -154,26 +200,36 @@ export function TrajectoryView({ const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number } | null>(null) - const ledgerRef = useRef<HTMLDivElement>(null) + const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ + readonly index: number + } | null>(null) const inspection = useHistory(snapshot => snapshot.inspection) + const historyLoading = useHistory(snapshot => + snapshot.state === 'cold' || snapshot.state === 'loading') + const hasOlderHistory = useHistory(snapshot => snapshot.hasMore) + const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq) const nodes = inspection.eventNodes const partial = inspection.partial const runningCalls = inspection.runningCalls const codeDispatches = inspection.codeDispatches - const loadAllHistoryRef = useRef(loadAllHistory) - loadAllHistoryRef.current = loadAllHistory + const loadHistoryTailRef = useRef(loadHistoryTail) + loadHistoryTailRef.current = loadHistoryTail + const historyControllerRef = useRef<AbortController | null>(null) useEffect(() => { const controller = new AbortController() - void loadAllHistoryRef.current(controller.signal) + historyControllerRef.current = controller + void loadHistoryTailRef.current(controller.signal) return () => { controller.abort() } }, []) const requests = inspection.requests const callSchemas = inspection.callSchemas + const historyContexts = inspection.contexts + const interruptedNodes = inspection.interruptedNodes const contexts = useMemo<readonly ConversationContext[]>( - () => inspection.contexts.length === 0 + () => historyContexts.length === 0 ? [{ id: 0, nodes }] - : inspection.contexts, - [inspection, nodes], + : historyContexts, + [historyContexts, nodes], ) const branches = useMemo( () => deriveTrajectoryContextBranches(contexts), @@ -183,18 +239,18 @@ export function TrajectoryView({ if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') const selectedNodes = useMemo(() => { const selected = new Map(currentBranch.nodes.map(node => [node.seq, node])) - for (const node of inspection.interruptedNodes) { + for (const node of interruptedNodes) { selected.set(node.seq, node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) - }, [currentBranch, inspection]) + }, [currentBranch.nodes, interruptedNodes]) const selectedRequests = useMemo( () => requests.filter(request => trajectoryBranchContainsRequest(currentBranch, request), ), [currentBranch, requests], ) - const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => { + const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => { const assistantsByStep = new Map<string, AssistantMessageNode>() for (const context of contexts) { for (const node of context.nodes) { @@ -295,63 +351,74 @@ export function TrajectoryView({ }) } - if (partial !== null && partial.step > 0) { - const key = `${partial.turn}\u0000${partial.step}` - const recorded = numbered.some(request => - `${request.turn}\u0000${request.step}` === key, - ) - if (!recorded) { - numbered.push({ - turn: partial.turn, - step: partial.step, - group: `Step ${partial.step}`, - number: orderedRequests.length + 1, - ...(currentBranch.latest.prompt?.config.provider === undefined - ? {} - : { provider: currentBranch.latest.prompt.config.provider }), - ...(currentBranch.latest.prompt?.config.model === undefined - ? {} - : { model: currentBranch.latest.prompt.config.model }), - ...(currentBranch.latest.prompt?.config === undefined - ? {} - : { requestConfig: currentBranch.latest.prompt.config }), - ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), - }) - } - } return numbered }, [ - contexts, currentBranch.latest.prompt, nodes, partial, requests, + contexts, nodes, requests, ]) - const requestNumbers = globalRequestNumbers - const turns = useMemo( - () => deriveTrajectoryLayout({ + const partialTurn = partial?.turn ?? null + const partialStep = partial?.step ?? null + const finalized = useMemo(() => { + const turns = deriveTrajectoryLayout({ nodes: selectedNodes, - partial, + partial: partialTurn === null || partialStep === null + ? null + : { turn: partialTurn, step: partialStep, blocks: [] }, runningCalls, requests: selectedRequests, callSchemas, codeDispatches, - }), - [ - selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches, - ], + }) + return { turns, lastIndex: lastCellIndex(turns) } + }, [ + selectedNodes, partialTurn, partialStep, + runningCalls, selectedRequests, callSchemas, codeDispatches, + ]) + const timelinePartialSignature = partialStructureSignature(partial) + const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null + ? null + : { + turn: partial.turn, + step: partial.step, + blocks: partial.blocks.map(block => timelineBlock(block)), + }, + [partialStep, partialTurn, timelinePartialSignature]) + const timelineTurns = useMemo( + () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex), + [finalized, timelinePartial], ) const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' : actualTime ? 'time' : 'sequence' - const searchMatchIndexes = useMemo( - () => searchMatches(turns, searchQuery), - [searchQuery, turns], + const finalizedSearchMatches = useMemo( + () => searchMatches(finalized.turns, searchQuery), + [finalized, searchQuery], ) - const timelineRange = timelineSelection?.branchId === currentBranch.id + const partialSearchTurns = useMemo( + () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), + [finalized.lastIndex, partial], + ) + const streamingCells = useMemo( + () => partialSearchTurns.flatMap(turn => + turn.groups.flatMap(group => group.cells), + ), + [partialSearchTurns], + ) + const partialSearchMatches = useMemo( + () => searchMatches(partialSearchTurns, searchQuery), + [partialSearchTurns, searchQuery], + ) + const searchMatchIndexes = useMemo( + () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), + [finalizedSearchMatches, partialSearchMatches], + ) + const timelineRange = timelineSelection?.branchKey === currentBranch.key ? timelineSelection.range : null const timelineFocusIndexes = useMemo( () => timelineRange === null ? null - : trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode), - [timelineMode, timelineRange, turns], + : trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode), + [timelineMode, timelineRange, timelineTurns], ) const handleRecordSelect = useCallback((index: number) => { if ( @@ -361,31 +428,22 @@ export function TrajectoryView({ setTimelineSelection(null) } }, [timelineFocusIndexes]) - useEffect(() => { - if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return - const ledger = ledgerRef.current - if (ledger === null) return - const focusedRows = [ - ...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'), - ] - const first = focusedRows.at(0) - const last = focusedRows.at(-1) - if (first === undefined || last === undefined) return - const focusHeight = - last.getBoundingClientRect().bottom - first.getBoundingClientRect().top - if (focusHeight > ledger.clientHeight) { - if (typeof first.scrollIntoView === 'function') { - first.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - return - } - const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)] - if (middle !== undefined && typeof middle.scrollIntoView === 'function') { - middle.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }, [timelineFocusIndexes]) + const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => { + setTimelineSelection(range === null ? null : { + branchKey: currentBranch.key, + range, + }) + }, [currentBranch.key]) + const handleTimelineRecordSelect = useCallback((index: number) => { + setTimelineSelection(null) + setTimelineRecordSelection({ index }) + setSelectedTimelineIndex(index) + }, []) + const handleTimelineRecordFocus = useCallback((index: number) => { + setTimelineRecordFocus({ index }) + }, []) const collapsibleTurnIds = useMemo( - () => turns + () => timelineTurns .filter(turn => turn.turn !== null && @@ -396,23 +454,25 @@ export function TrajectoryView({ 0, ) > 1) .flatMap(turn => turn.turn === null ? [] : [turn.turn]), - [turns], + [timelineTurns], ) const allTurnsCollapsed = collapsibleTurnIds.length > 0 && collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) const collapsibleAssistantIds = useMemo(() => { - const ids: number[] = [] - for (const turn of turns) { + const ids: string[] = [] + for (const turn of timelineTurns) { const cells = turn.groups.flatMap(group => group.cells) for (let i = 0; i < cells.length; i++) { const cell = cells[i] if (cell?.kind !== 'message') continue const next = cells[i + 1] - if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index) + if (next?.kind === 'tool' || next?.kind === 'subtool') { + ids.push(trajectoryRecordId(cell)) + } } } return ids - }, [turns]) + }, [timelineTurns]) const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 && collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) @@ -437,11 +497,11 @@ export function TrajectoryView({ }) } - const toggleAssistant = (index: number) => { + const toggleAssistant = (id: string) => { setCollapsedAssistants((current) => { const collapsed = new Set(current) - if (collapsed.has(index)) collapsed.delete(index) - else collapsed.add(index) + if (collapsed.has(id)) collapsed.delete(id) + else collapsed.add(id) return collapsed }) } @@ -458,6 +518,13 @@ export function TrajectoryView({ }) } + const loadEarlierHistory = useCallback(() => { + const signal = historyControllerRef.current?.signal + return signal?.aborted === false + ? loadOlderHistory(signal) + : Promise.resolve(false) + }, [loadOlderHistory]) + return ( <div className={css.root} data-conversation-composer-overlay=""> <TrajectoryToolbar @@ -479,45 +546,33 @@ export function TrajectoryView({ onSearchQueryChange={setSearchQuery} /> <TrajectoryTimeline - turns={turns} + turns={timelineTurns} mode={timelineMode} range={timelineRange} + hasEarlierRecords={hasOlderHistory} + onLoadEarlier={loadEarlierHistory} selectedIndex={selectedTimelineIndex} searchMatchIndexes={searchMatchIndexes} - onRangeChange={(range) => { - setTimelineSelection(range === null ? null : { - branchId: currentBranch.id, - range, - }) - }} - onRecordSelect={(index) => { - setTimelineSelection(null) - setTimelineRecordSelection({ index }) - setSelectedTimelineIndex(index) - const row = ledgerRef.current - ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`) - if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { - row.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }} - onRecordFocus={(index) => { - const row = ledgerRef.current - ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`) - if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { - row.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }} + onRangeChange={handleTimelineRangeChange} + onRecordSelect={handleTimelineRecordSelect} + onRecordFocus={handleTimelineRecordFocus} /> - <div ref={ledgerRef} className={css.ledger}> + <div className={css.ledger}> <TrajectoryTable - key={currentBranch.id} + key={currentBranch.key} requestNumbers={requestNumbers} - turns={turns} + turns={timelineTurns} + streamingCells={streamingCells} timelineFocusIndexes={timelineFocusIndexes} searchMatchIndexes={searchMatchIndexes} onSelectedIndexChange={setSelectedTimelineIndex} onRecordSelect={handleRecordSelect} recordSelection={timelineRecordSelection} + recordFocus={timelineRecordFocus} + historyLoading={historyLoading} + historyStartSeq={historyBaseSeq} + hasOlderRecords={hasOlderHistory} + onLoadOlder={loadEarlierHistory} onClearSelection={() => { setTimelineSelection(null) }} collapsedTurns={collapsedTurns} onToggleTurn={toggleTurn} diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts index 3b123b051b..bc5b74057b 100644 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ b/packages/client/ui-trajectory/src/client/context-branches.ts @@ -7,6 +7,8 @@ import type { /** One continuous context branch; compactions stay inline while rewinds start a successor branch. */ export interface TrajectoryContextBranch { id: number + /** Identity stable when older context generations are prepended. */ + key: string contexts: readonly ConversationContext[] latest: ConversationContext nodes: readonly ConversationNode[] @@ -18,6 +20,7 @@ export interface TrajectoryContextBranch { interface MutableBranch { id: number + key: string contexts: ConversationContext[] latest: ConversationContext nodes: Map<number, ConversationNode> @@ -63,6 +66,9 @@ export function deriveTrajectoryContextBranches( ) mutable.push({ id: context.id, + key: context.origin === 'rewind' && context.originSeq !== undefined + ? `rewind:${context.originSeq}` + : 'root', contexts: [context], latest: context, nodes: new Map( @@ -84,6 +90,7 @@ export function deriveTrajectoryContextBranches( } return mutable.map(branch => ({ id: branch.id, + key: branch.key, contexts: branch.contexts, latest: branch.latest, nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq), diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 94f512a9a8..a6b5a4282d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -10,14 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -/** - * Required services (cordis fiber inject). 'conversation' is an ordering - * edge, not a call dependency: the 'conversation.view' slot is declared by - * ui-conversation's apply (which then provides the service), and register() - * into an undeclared slot throws — service waiting is what orders this - * apply after the declaring one. - */ -export const inject = ['slots', 'conversation', 'sessionHistory'] +/** Required services: the conversation view slot and independent history source. */ +export const inject = ['slots', 'sessionHistory'] /** * Client plugin body: register the trajectory view tab. The registration @@ -26,7 +20,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory'] */ export function apply(ctx: Context): void { const duration = createTrajectoryDurationStore() - ctx.slots.register({ + ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, @@ -35,9 +29,10 @@ export function apply(ctx: Context): void { const history = ctx.sessionHistory.source(sessionId) return { hooks: { history, duration }, - loadAllHistory: signal => history.loadAll(signal), + loadHistoryTail: signal => history.loadTail(signal), + loadOlderHistory: signal => history.loadOlder(signal), setActualDuration: (value) => { duration.set(value) }, } }, - }, TrajectoryView) + }, TrajectoryView)) } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 3364ebce3e..273b4fc32a 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -17,6 +17,7 @@ import type { TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' +import { formatElapsedSeconds } from './trajectory-record.ts' /** One Message or Step group inside a turn. */ export interface TrajectoryGroupModel { @@ -75,7 +76,7 @@ const PREVIEW_OUTPUT_CHARACTERS = 512 type InputNode = Extract< ConversationSnapshot['nodes'][number], - { kind: 'user' | 'steering' | 'context' } + { kind: 'user' | 'context' } > type OrderedLayoutEntry = @@ -325,19 +326,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T continue } const { node, nodeIndex: i } = entry - if (node.kind === 'user' || node.kind === 'steering') { + if (node.kind === 'user') { // user/message has no turn on the wire; enclose it in the next assistant // (or partial) turn, else open the turn after the last assistant. - const turn = node.kind === 'steering' - ? node.turn - : enclosingUserTurn(nodes, i, partial, lastAssistantTurn) + const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { index: ++index, kind: 'user', ...inputCellDetail(node), - opensTurn: node.kind === 'user', + opensTurn: true, }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -453,7 +452,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T else for (const laid of laidList) pushMessage(call.turn, laid) } - // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. + // Orphan turn-0 cells (orphaned tools) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) @@ -475,6 +474,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ].sort((left, right) => firstCellIndex(left) - firstCellIndex(right)) } +/** + * Append the changing in-flight assistant cells to a stable finalized layout. + * @param turns - Finalized layout derived with an empty-block partial anchor. + * @param partial - Current in-flight assistant projection. + * @param lastIndex - Highest cell index in the finalized layout. + * @returns The original layout without a partial, otherwise a layout sharing every unaffected turn. + */ +export function appendTrajectoryPartialLayout( + turns: readonly TrajectoryTurnModel[], + partial: ConversationSnapshot['partial'], + lastIndex: number, +): readonly TrajectoryTurnModel[] { + if (partial === null) return turns + const partialTurn = deriveTrajectoryLayout({ + nodes: [], + partial, + runningCalls: [], + codeDispatches: new Map(), + }).at(0) + if (partialTurn === undefined) return turns + const streamed: TrajectoryTurnModel = { + ...partialTurn, + groups: partialTurn.groups.map(group => ({ + ...group, + cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })), + })), + } + const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn) + if (turnIndex === -1) return [...turns, streamed] + const current = turns[turnIndex] + /* v8 ignore next -- findIndex proved the dense array position exists. */ + if (current === undefined) return turns + const groups = [...current.groups] + for (const streamedGroup of streamed.groups) { + const groupIndex = groups.findIndex(group => group.title === streamedGroup.title) + if (groupIndex === -1) { + groups.push(streamedGroup) + continue + } + const group = groups[groupIndex] + /* v8 ignore next -- findIndex proved the dense array position exists. */ + if (group === undefined) continue + const streamedCallIds = new Set( + streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]), + ) + groups[groupIndex] = { + ...streamedGroup, + cells: [ + ...group.cells.filter(cell => + cell.requestOnly !== true + && (cell.callId === undefined || !streamedCallIds.has(cell.callId)), + ), + ...streamedGroup.cells, + ], + } + } + const updated = [...turns] + updated[turnIndex] = { ...current, groups } + return updated +} + function attachToolSchema( laid: LaidCell, callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined, @@ -542,9 +602,7 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { function formatGroupDuration(seconds: number): string | undefined { if (!Number.isFinite(seconds)) return undefined - const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded} s` - return `${rounded.toFixed(1)} s` + return formatElapsedSeconds(seconds) } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -566,6 +624,7 @@ function expandAssistant( callStarts: ReadonlyMap<string, number>, opts?: { streaming?: boolean }, ): LaidCell[] { + if (opts?.streaming === true && node.blocks.length === 0) return [] const out: LaidCell[] = [] let index = startIndex - 1 const usage = node.usage as UsageLike | undefined @@ -585,6 +644,7 @@ function expandAssistant( .join('\n\n') const message: TrajectoryCellProps = { index: ++index, + recordId: `assistant\u0000${node.turn}\u0000${node.step}`, kind: 'message', sourceSeq: node.seq, text: messageText !== '' @@ -733,7 +793,7 @@ function stringifySourceValue(value: unknown): string { } /** - * Turn that encloses a user/message: next assistant/steering turn, else the + * Turn that encloses a user/message: next assistant turn, else the * in-flight partial, else the turn after the last finalized assistant (or 1). */ function enclosingUserTurn( @@ -746,7 +806,7 @@ function enclosingUserTurn( const n = nodes[i] /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ if (n === undefined) continue - if (n.kind === 'assistant' || n.kind === 'steering') return n.turn + if (n.kind === 'assistant') return n.turn } if (partial !== null) return partial.turn if (lastAssistantTurn !== null) return lastAssistantTurn + 1 @@ -770,7 +830,7 @@ function firstVisibleTurn( partial: ConversationSnapshot['partial'], ): number { const turns = nodes.flatMap(node => - (node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0 + node.kind === 'assistant' && node.turn > 0 ? [node.turn] : [], ) diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts index 6b9eac10b7..6d3a0ef917 100644 --- a/packages/client/ui-trajectory/src/client/timeline.ts +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -1,6 +1,7 @@ /** Operation-sequence and recorded-time projections for the trajectory overview. */ import type { TrajectoryTurnModel } from './layout.ts' +import { formatDurationMillis } from './trajectory-record.ts' import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' /** Horizontal projection used by the trajectory timeline. */ @@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange { } /** - * Format a timeline duration with a compact unit. + * Format a timeline duration as an integer-millisecond label. * @param milliseconds - Non-negative duration in milliseconds. - * @returns Millisecond or second label. + * @returns Millisecond label with thousands separators. */ export function formatTimelineOffset(milliseconds: number): string { - if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` - const seconds = milliseconds / 1_000 - return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s` + return formatDurationMillis(milliseconds) } function laneFor(kind: TrajectoryCellKind): number { diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index e33bfb873c..a979278f3f 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -37,6 +37,8 @@ export interface TrajectorySourceBlock { export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> { /** 1-based record index shown as `#N`. */ index: number + /** Projection-stable identity when no single source event owns the record lifecycle. */ + recordId?: string kind: TrajectoryCellKind /** Single-line summary; CSS ellipsis when it overflows. */ text: string @@ -92,13 +94,32 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> { } /** - * Format own-duration for the trailing time column. + * Resolve the identity that survives prepending older projected records. + * @param cell - Projected trajectory record. + * @returns Stable identity from the owning event or tool call, with a fixture fallback. + */ +export function trajectoryRecordId(cell: TrajectoryCellProps): string { + if (cell.recordId !== undefined) return cell.recordId + if (cell.callId !== undefined) return `${cell.kind}\u0000call\u0000${cell.callId}` + if (cell.sourceSeq !== undefined) return `${cell.kind}\u0000seq\u0000${cell.sourceSeq}` + return `${cell.kind}\u0000index\u0000${cell.index}` +} + +/** + * Format a duration in milliseconds with thousands separators. + * @param milliseconds - Duration in milliseconds, or `null` when absent. + * @returns `—` when unknown, otherwise an integer-millisecond label. + */ +export function formatDurationMillis(milliseconds: number | null): string { + if (milliseconds === null || !Number.isFinite(milliseconds)) return '—' + return `${Math.round(milliseconds).toLocaleString('en-US')} ms` +} + +/** + * Format an elapsed duration given in seconds as a millisecond label. * @param seconds - Duration seconds, or `null` when absent. - * @returns `—` when unknown, otherwise a seconds label. + * @returns `—` when unknown, otherwise an integer-millisecond label. */ export function formatElapsedSeconds(seconds: number | null): string { - if (seconds === null || !Number.isFinite(seconds)) return '—' - const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded} s` - return `${rounded.toFixed(1)} s` + return formatDurationMillis(seconds === null ? null : seconds * 1000) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-virtual-rows.ts b/packages/client/ui-trajectory/src/client/trajectory-virtual-rows.ts new file mode 100644 index 0000000000..0b968197a6 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-virtual-rows.ts @@ -0,0 +1,83 @@ +/** Pure projection from trajectory records to measurable virtual ledger rows. */ + +import type { TrajectoryCellProps } from './trajectory-record.ts' +import { trajectoryRecordId } from './trajectory-record.ts' + +const CONTENT_ROW_HEIGHT = 30 +const COLLAPSED_SUMMARY_HEIGHT = 20 +const TERMINAL_BOUNDARY_HEIGHT = 9 + +/** Minimal record shape required by the trajectory virtual-row projection. */ +export interface VirtualizableTrajectoryRecord { + cell: TrajectoryCellProps + collapsedSummaryKind?: 'turn' | 'assistant' +} + +/** One logical record retained inside a measurable virtual row. */ +export interface TrajectoryVirtualRowEntry<T extends VirtualizableTrajectoryRecord> { + logicalIndex: number + record: T +} + +/** One virtualizer item, which may carry zero-height request boundaries. */ +export interface TrajectoryVirtualRow<T extends VirtualizableTrajectoryRecord> { + entries: readonly TrajectoryVirtualRowEntry<T>[] + height: number + key: string +} + +/** + * Derive the DOM-safe row identity shared by React, the virtualizer, and + * browser scroll contracts. + * @param record - Display record whose identity is required. + * @returns Stable record identity with a suffix for synthetic fold summaries. + */ +export function trajectoryVirtualRecordKey( + record: VirtualizableTrajectoryRecord, +): string { + const identity = encodeURIComponent(trajectoryRecordId(record.cell)) + return record.collapsedSummaryKind === undefined + ? identity + : `${identity}\u0000summary\u0000${record.collapsedSummaryKind}` +} + +/** + * Attach separator-only records to the next content row so the virtualizer + * never owns a zero-height item. A terminal separator retains its CSS-owned + * lower-marker clearance as a standalone item. + * @param records - Final search/fold projection in ledger order. + * @returns Measurable virtual rows with original logical positions retained. + */ +export function groupTrajectoryVirtualRows<T extends VirtualizableTrajectoryRecord>( + records: readonly T[], +): readonly TrajectoryVirtualRow<T>[] { + const rows: TrajectoryVirtualRow<T>[] = [] + let pending: TrajectoryVirtualRowEntry<T>[] = [] + + for (const [logicalIndex, record] of records.entries()) { + const entry = { logicalIndex, record } + if (record.cell.requestOnly === true) { + pending.push(entry) + continue + } + const entries = [...pending, entry] + pending = [] + rows.push({ + entries, + height: record.collapsedSummaryKind === undefined + ? CONTENT_ROW_HEIGHT + : COLLAPSED_SUMMARY_HEIGHT, + key: trajectoryVirtualRecordKey(record), + }) + } + + if (pending.length > 0) { + rows.push({ + entries: pending, + height: TERMINAL_BOUNDARY_HEIGHT, + key: pending.map(candidate => trajectoryVirtualRecordKey(candidate.record)).join('|'), + }) + } + + return rows +} diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx index 30f5814998..2e7c0ddb45 100644 --- a/packages/client/ui-trajectory/tests/cell.spec.tsx +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -10,17 +10,33 @@ import { TrajectoryCell, type TrajectoryCellKind, } from '../src/client/TrajectoryCell.tsx' +import { formatDurationMillis } from '../src/client/trajectory-record.ts' afterEach(cleanup) +describe('formatDurationMillis', () => { + it('formats exact millisecond labels with thousands separators', () => { + expect(formatDurationMillis(0)).toBe('0 ms') + expect(formatDurationMillis(29)).toBe('29 ms') + expect(formatDurationMillis(500)).toBe('500 ms') + expect(formatDurationMillis(1_500)).toBe('1,500 ms') + expect(formatDurationMillis(235_200)).toBe('235,200 ms') + expect(formatDurationMillis(null)).toBe('—') + expect(formatDurationMillis(Number.NaN)).toBe('—') + }) +}) + describe('formatElapsedSeconds', () => { it('formats known durations and uses an em dash when absent', () => { expect(formatElapsedSeconds(null)).toBe('—') - expect(formatElapsedSeconds(235)).toBe('235 s') - expect(formatElapsedSeconds(235.0)).toBe('235 s') - expect(formatElapsedSeconds(235.2)).toBe('235.2 s') - expect(formatElapsedSeconds(235.25)).toBe('235.3 s') - expect(formatElapsedSeconds(0)).toBe('0 s') + expect(formatElapsedSeconds(235)).toBe('235,000 ms') + expect(formatElapsedSeconds(235.0)).toBe('235,000 ms') + expect(formatElapsedSeconds(235.2)).toBe('235,200 ms') + expect(formatElapsedSeconds(235.25)).toBe('235,250 ms') + expect(formatElapsedSeconds(0)).toBe('0 ms') + expect(formatElapsedSeconds(0.029)).toBe('29 ms') + expect(formatElapsedSeconds(0.5)).toBe('500 ms') + expect(formatElapsedSeconds(1.5)).toBe('1,500 ms') expect(formatElapsedSeconds(Number.NaN)).toBe('—') }) }) @@ -38,7 +54,7 @@ describe('TrajectoryCell', () => { expect(screen.getByText('#6')).toBeTruthy() expect(screen.getByText('Tool')).toBeTruthy() expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() - expect(screen.getByText('5 s')).toBeTruthy() + expect(screen.getByText('5,000 ms')).toBeTruthy() }) it('Message rows expose Input / Output / Think metric columns before time', () => { @@ -57,11 +73,11 @@ describe('TrajectoryCell', () => { expect(screen.getByText('136')).toBeTruthy() expect(screen.getByText('381')).toBeTruthy() expect(screen.getByText('155')).toBeTruthy() - expect(screen.getByText('235.2 s')).toBeTruthy() + expect(screen.getByText('235,200 ms')).toBeTruthy() const texts = [...container.querySelectorAll('span')].map(el => el.textContent) expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) - expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s')) + expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235,200 ms')) }) it('selected marks the row for the brand-primary inset ring', () => { diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 50cb101401..579a97337a 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -46,6 +46,7 @@ describe('tsdown client artifact', () => { const modules = new Map<string, unknown>([ ['react', await import('react')], ['react/jsx-runtime', await import('react/jsx-runtime')], + ['react-dom', await import('react-dom')], ['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')], ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')], ]) @@ -60,7 +61,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) + expect(surface.inject).toEqual(['slots', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -72,10 +73,8 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and - // 'sessionHistory' for its per-session history source; this bench - // supplies both. - ctx.provide('conversation', {}) + // The plugin reads sessionHistory for its per-session history source; + // slot availability is tracked by slots.inject. ctx.provide('sessionHistory', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index ae1fecbaea..e608b9fd68 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -13,6 +13,8 @@ const checkpoint = { time: 100, content: [], source: { kind: 'plugin', plugin: 'compact' }, + provenance: { role: 'inject', label: 'compact' }, + form: null, } as ConversationNode const abandoned = { @@ -71,6 +73,7 @@ describe('trajectory context branches', () => { const branches = deriveTrajectoryContextBranches(contexts) const successor = branches[1]! + expect(successor.key).toBe('rewind:110') expect(successor.nodes.map(node => node.seq)).toEqual([110]) expect(trajectoryBranchContainsRequest( successor, @@ -85,4 +88,15 @@ describe('trajectory context branches', () => { request('assistant', 111), )).toBe(true) }) + + it('keeps branch identity when prepended generations shift local ids', () => { + const branch = (id: number) => deriveTrajectoryContextBranches([{ + id, + origin: 'rewind', + originSeq: 110, + nodes: [current], + }])[0] + + expect(branch(1)?.key).toBe(branch(9)?.key) + }) }) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index dbd20b53f9..bd25cc4d51 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -11,7 +11,9 @@ import type { import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' -import { deriveTrajectoryLayout } from '../src/client/layout.ts' +import { + appendTrajectoryPartialLayout, deriveTrajectoryLayout, +} from '../src/client/layout.ts' afterEach(cleanup) @@ -102,6 +104,70 @@ describe('deriveTrajectoryLayout', () => { }) }) + it('appends a streaming partial without rebuilding unaffected finalized turns', () => { + const nodes = [{ + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'text', text: 'finalized' }], + }] as unknown as ConversationSnapshot['nodes'] + const partial = { + turn: 2, + step: 1, + blocks: [{ kind: 'reasoning' as const, text: 'streaming' }], + } + const request = { + purpose: 'assistant', startSeq: 3, turn: 2, step: 1, + startedAt: 3_000, completedAt: null, status: 'running', + } as unknown as RequestView + const base = deriveTrajectoryLayout({ + codeDispatches: new Map(), + nodes, + partial: { ...partial, blocks: [] }, + requests: [request], + runningCalls: [], + }) + expect(base).toHaveLength(1) + + const streamed = appendTrajectoryPartialLayout(base, partial, 1) + + expect(streamed[0]).toBe(base[0]) + expect(streamed).toHaveLength(2) + expect(streamed[1]?.groups[0]?.cells).toMatchObject([{ + index: 2, + kind: 'message', + text: 'streaming', + timeSeconds: null, + }]) + expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined() + }) + + it('replaces a running-call placeholder with the matching streamed tool call', () => { + const partial = { + turn: 1, + step: 1, + blocks: [{ + kind: 'tool-call' as const, + callId: 'c1', + name: 'bash', + argsRaw: '{"command":"pwd"}', + }], + } + const base = deriveTrajectoryLayout({ + codeDispatches: new Map(), + nodes: [], + partial: { ...partial, blocks: [] }, + runningCalls: [{ + callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', + turn: 1, step: 1, time: 9_000, callView: null, + }], + }) + + const streamed = appendTrajectoryPartialLayout(base, partial, 1) + const cells = streamed[0]?.groups[0]?.cells ?? [] + + expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool']) + expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1) + }) + it('omits duration when node times are missing instead of rendering NaN', () => { const nodes = [ { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, @@ -141,7 +207,7 @@ describe('deriveTrajectoryLayout', () => { }, ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2') + expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2') }) it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index b9f5e5d7b7..dc4d2c9188 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -2,11 +2,15 @@ /** Trajectory ledger selection, details, status, and fold behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.restoreAllMocks() + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') +}) const TURNS: readonly TrajectoryTurnModel[] = [{ turn: 1, @@ -56,11 +60,33 @@ const TURNS: readonly TrajectoryTurnModel[] = [{ const FOLD_PROPS = { collapsedTurns: new Set<number>(), onToggleTurn: () => {}, - collapsedAssistants: new Set<number>(), + collapsedAssistants: new Set<string>(), onToggleAssistant: () => {}, } describe('TrajectoryTable', () => { + it('shows a muted placeholder for an assistant response containing only tool calls', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: 'Tool call only', + sourceBlocks: [{ + type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', + }], + timeSeconds: 1, + }], + }], + }] + + render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />) + + expect(screen.getByText('(tool call only)')).toBeTruthy() + }) + it('shows assistant timing facts after keyboard selection', () => { render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) @@ -71,6 +97,27 @@ describe('TrajectoryTable', () => { expect(screen.getByText('20.0 tok/s')).toBeTruthy() }) + it('shows a tool record Duration as exact milliseconds', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'bash · {"command":"pwd"}', + inputDetail: '{"command":"pwd"}', + timeSeconds: 1.5, + }], + }], + }] + + render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />) + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + + expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy() + }) + it('breaks output tokens into labeled reasoning and content rows', () => { render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) @@ -83,6 +130,17 @@ describe('TrajectoryTable', () => { expect(screen.getByText('15 tok')).toBeTruthy() }) + it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => { + render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + + const panel = screen.getByRole('tabpanel') + expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1) + + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) + expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull() + }) + it('keeps long thinking collapsed until the user asks to render it', () => { const thinking = 'private chain '.repeat(1_000) const turns: readonly TrajectoryTurnModel[] = [{ @@ -163,6 +221,93 @@ describe('TrajectoryTable', () => { expect(onClearSelection).toHaveBeenCalledOnce() }) + it('keeps the selected record when older rows shift projection indexes', () => { + const tail = (index: number): TrajectoryTurnModel => ({ + turn: 2, + groups: [{ + title: 'Step 1', + cells: [{ + index, + kind: 'message', + sourceSeq: 100, + text: 'selected tail response', + outputDetail: 'selected tail response detail', + timeSeconds: 1, + }], + }], + }) + const view = render( + <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />, + ) + fireEvent.click(screen.getByRole('row', { name: /selected tail response/ })) + + view.rerender( + <TrajectoryTable + turns={[{ + turn: 1, + groups: [{ + title: 'Message', + cells: [{ + index: 1, + kind: 'user', + sourceSeq: 1, + text: 'older prompt', + timeSeconds: 0, + }], + }], + }, tail(2)]} + {...FOLD_PROPS} + />, + ) + + expect(screen.getByRole('row', { name: /selected tail response/ }) + .getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('selected tail response detail')).toBeTruthy() + }) + + it('keeps a selected request when prepending changes its display number', () => { + const tail = (index: number): TrajectoryTurnModel => ({ + turn: 2, + groups: [{ + title: 'Step 1', + cells: [{ + index, + kind: 'message', + sourceSeq: 100, + text: 'tail response', + timeSeconds: 1, + }], + }], + }) + const view = render( + <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />, + ) + fireEvent.click(screen.getByRole('button', { name: 'Request #1' })) + + view.rerender( + <TrajectoryTable + turns={[{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + sourceSeq: 1, + text: 'older response', + timeSeconds: 1, + }], + }], + }, tail(2)]} + {...FOLD_PROPS} + />, + ) + + expect(screen.getByRole('button', { name: 'Request #2' }) + .getAttribute('aria-pressed')).toBe('true') + expect(screen.getByText('Request #2')).toBeTruthy() + }) + it('follows appended records only while the ledger is already at the bottom', () => { const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) const tablePane = screen.getByRole('table').parentElement as HTMLElement @@ -210,6 +355,216 @@ describe('TrajectoryTable', () => { expect(tablePane.scrollTop).toBe(20) }) + it('preserves the visible anchor when the last older page disables virtualization', async () => { + let resolveOlder: ((advanced: boolean) => void) | undefined + const older = new Promise<boolean>((resolve) => { resolveOlder = resolve }) + const onLoadOlder = vi.fn(() => older) + const view = render( + <TrajectoryTable + turns={TURNS} + {...FOLD_PROPS} + historyStartSeq={1} + hasOlderRecords + onLoadOlder={onLoadOlder} + />, + ) + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 0 + fireEvent.scroll(tablePane) + fireEvent.scroll(tablePane) + + await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() }) + expect(screen.getByRole('status').textContent).toContain('Loading earlier history…') + resolveOlder?.(true) + await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() }) + scrollHeight = 260 + view.rerender( + <TrajectoryTable + turns={[{ + turn: 0, + groups: [{ + title: 'Step 1', + cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }], + }], + }, ...TURNS]} + {...FOLD_PROPS} + historyStartSeq={0} + onLoadOlder={onLoadOlder} + />, + ) + + expect(tablePane.scrollTop).toBe(60) + }) + + it('covers the ledger while the initial tail is loading', () => { + const view = render( + <TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />, + ) + + expect(screen.getByRole('status').textContent).toContain('Loading trajectory…') + expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull() + + view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) + + expect(screen.queryByRole('status')).toBeNull() + expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true') + }) + + it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(), + }) + const view = render( + <TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />, + ) + + await waitFor(() => { + expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy() + }) + }) + + it('mounts only the visible window for a long ledger', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + const scrollTo = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: scrollTo, + }) + const cells = Array.from({ length: 500 }, (_, index) => ({ + index: index + 1, + kind: 'context' as const, + text: `Context ${index + 1}`, + timeSeconds: 0, + })) + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ title: 'Context', cells }], + }] + const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />) + + await waitFor(() => { + expect(view.container.querySelectorAll('tr[data-virtual-position]').length) + .toBeGreaterThan(0) + }) + expect(view.container.querySelectorAll('tr[data-virtual-position]').length) + .toBeLessThan(cells.length) + expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500') + expect(view.container.querySelector('tr[data-trajectory-row-key]') + ?.getAttribute('aria-rowindex')).toBe('1') + expect(scrollTo).toHaveBeenCalled() + expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy() + expect(screen.getByText('Context 1')).toBeTruthy() + expect(screen.queryByText('Context 500')).toBeNull() + + const tablePane = screen.getByRole('table').parentElement as HTMLElement + tablePane.scrollTop = 9_000 + fireEvent.scroll(tablePane) + await waitFor(() => { + expect(Number(view.container.querySelector( + 'tr[data-virtual-position]', + )?.getAttribute('data-virtual-position'))).toBeGreaterThan(0) + }) + expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy() + expect(screen.queryByText('Context 1')).toBeNull() + }) + + it('does not re-scroll a virtual ledger when streaming only changes row content', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + const scrollTo = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: scrollTo, + }) + const cells = Array.from({ length: 500 }, (_, index) => ({ + index: index + 1, + kind: 'context' as const, + sourceSeq: index + 1, + text: `Context ${index + 1}`, + timeSeconds: 0, + })) + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ title: 'Context', cells }], + }] + const view = render( + <TrajectoryTable + turns={turns} + {...FOLD_PROPS} + />, + ) + await waitFor(() => { + expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy() + }) + scrollTo.mockClear() + + view.rerender( + <TrajectoryTable + turns={turns} + streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]} + {...FOLD_PROPS} + />, + ) + + expect(scrollTo).not.toHaveBeenCalled() + expect(screen.getByText('Context 1 streaming update')).toBeTruthy() + }) + + it('keeps the virtual tail reachable with collapsed-summary row heights', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(), + }) + const turns: readonly TrajectoryTurnModel[] = Array.from( + { length: 101 }, + (_, index) => ({ + turn: index + 1, + groups: [{ + title: 'Step 1', + cells: [ + { + index: index * 2 + 1, + kind: 'message' as const, + sourceSeq: index * 2 + 1, + text: `Message ${index + 1}`, + timeSeconds: 1, + }, + { + index: index * 2 + 2, + kind: 'tool' as const, + callId: `call-${index + 1}`, + text: `Tool ${index + 1}`, + timeSeconds: 1, + }, + ], + }], + }), + ) + const collapsedTurns = new Set(turns.flatMap(turn => + turn.turn === null ? [] : [turn.turn])) + const view = render( + <TrajectoryTable + turns={turns} + {...FOLD_PROPS} + collapsedTurns={collapsedTurns} + />, + ) + const tablePane = screen.getByRole('table').parentElement as HTMLElement + tablePane.scrollTop = 5_000 + fireEvent.scroll(tablePane) + + await waitFor(() => { + expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy() + }) + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 0650e28746..548f51aceb 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -73,6 +73,7 @@ function historySnapshot( state: 'ready', error: null, hasMore: false, + baseSeq: nodes[0]?.seq ?? 0, inspection: { eventNodes: nodes, contexts: [{ id: 0, nodes }], @@ -89,11 +90,15 @@ function historySnapshot( function standaloneHistory( snapshot: SessionHistorySnapshot, -): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> { +): Pick< + ComponentProps<typeof TrajectoryView>, + 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory' +> { const store = createSnapshotStore(snapshot) return { useHistory: bindSnapshotSelector(store), - loadAllHistory: () => Promise.resolve(), + loadHistoryTail: () => Promise.resolve(), + loadOlderHistory: () => Promise.resolve(false), } } @@ -145,13 +150,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { async function bench(snapshot = historySnapshot(NODES)) { const ctx = new Context() const slots = new SlotsService(ctx) - const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) + const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve()) + const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false)) const historyStore = createSnapshotStore(snapshot) const history: SessionHistoryFace = { sessionId: SID, getSnapshot: () => historyStore.getSnapshot(), subscribe: listener => historyStore.subscribe(listener), - loadAll: loadAllHistory, + loadTail: loadHistoryTail, + loadOlder: loadOlderHistory, } // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ @@ -161,13 +168,10 @@ async function bench(snapshot = historySnapshot(NODES)) { const chatBody = vi.fn(() => <div data-testid="chat-body" />) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) - // 'conversation' inject is an ordering edge; the bench declares the ring - // itself, so a stub satisfies the wait. - ctx.provide('conversation', {}) ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadAllHistory } + return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -201,7 +205,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ? (() => { const trajectory = injected as TrajectoryViewInjected return { - loadAllHistory: trajectory.loadAllHistory, + loadHistoryTail: trajectory.loadHistoryTail, + loadOlderHistory: trajectory.loadOlderHistory, setActualDuration: trajectory.setActualDuration, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), @@ -295,9 +300,9 @@ describe('tab switching in ConversationRoot', () => { expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { - expect(b.loadAllHistory).toHaveBeenCalledOnce() + expect(b.loadHistoryTail).toHaveBeenCalledOnce() }) - const signal = b.loadAllHistory.mock.calls[0]?.[0] + const signal = b.loadHistoryTail.mock.calls[0]?.[0] expect(signal?.aborted).toBe(false) fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) expect(signal?.aborted).toBe(true) @@ -572,14 +577,53 @@ describe('timeline projection', () => { expect(view.container.querySelector('[role="tooltip"]')).toBeNull() act(() => { vi.advanceTimersByTime(1) }) const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]') - expect(tooltip?.textContent).toContain('Total 2.0 s') + expect(tooltip?.textContent).toContain('Total 2,000 ms') expect(tooltip?.textContent).toContain('TTFT 500 ms') - expect(tooltip?.textContent).toContain('Decoding 1.5 s') + expect(tooltip?.textContent).toContain('Decoding 1,500 ms') } finally { vi.useRealTimers() } }) + it('marks an unloaded history prefix without inventing timeline duration', () => { + const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {})) + const view = render( + <TrajectoryTimeline + turns={turns} + mode="sequence" + range={null} + hasEarlierRecords + onLoadEarlier={onLoadEarlier} + onRangeChange={vi.fn()} + />, + ) + + const boundary = screen.getByLabelText('Load earlier history') + expect(boundary.getAttribute('data-earlier-history')).not.toBeNull() + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 }) + expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy() + fireEvent.pointerEnter(boundary) + expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() + fireEvent.focus(boundary) + expect(screen.getByRole('tooltip').textContent) + .toContain('Click to load earlier history') + fireEvent.click(boundary) + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Loading earlier history')).toBeTruthy() + + view.rerender( + <TrajectoryTimeline + turns={turns} + mode="sequence" + range={null} + onRangeChange={vi.fn()} + />, + ) + expect(screen.queryByLabelText('Load earlier history')).toBeNull() + expect(screen.queryByLabelText('Loading earlier history')).toBeNull() + }) + it('cancels native scrolling across the timeline while zooming', () => { render( <TrajectoryTimeline @@ -614,7 +658,35 @@ describe('timeline projection', () => { const span = view.container.querySelector<HTMLElement>('[data-timeline-span]') expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') expect(span?.style.getPropertyValue('--trajectory-span-gap')) - .toBe('clamp(0.25px, 0.8%, 1px)') + .toBe('min(0.8%, 1px)') + }) + + it('keeps dense sequence spans proportional before applying the pixel floor', () => { + const denseTurns = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: Array.from({ length: 400 }, (_, index) => ({ + index, + kind: 'message' as const, + text: `message ${index}`, + timeSeconds: 1, + })), + }], + }] + const view = render( + <TrajectoryTimeline + turns={denseTurns} + mode="sequence" + range={null} + onRangeChange={vi.fn()} + />, + ) + + const span = view.container.querySelector<HTMLElement>('[data-timeline-span]') + expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%') + expect(span?.style.getPropertyValue('--trajectory-span-gap')) + .toBe('min(0.02%, 1px)') }) it('clears the selection without changing zoom on a zoomed right click', () => { @@ -624,15 +696,18 @@ describe('timeline projection', () => { turns={longTurns} mode="sequence" range={{ start: 2, end: 4 }} + hasEarlierRecords onRangeChange={onRangeChange} />, ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + expect(screen.getByLabelText('Load earlier history')).toBeTruthy() vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), }) fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + expect(screen.queryByLabelText('Load earlier history')).toBeNull() const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]') const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') expect(domainWidth).not.toBe('100%') @@ -1065,7 +1140,8 @@ describe('TrajectoryView branches', () => { {...standaloneProps([])} {...standaloneDuration()} useHistory={bindSnapshotSelector(store)} - loadAllHistory={vi.fn(() => Promise.resolve())} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) @@ -1075,6 +1151,74 @@ describe('TrajectoryView branches', () => { expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) }) + it('does not remount the ledger when prepending shifts a rewind generation id', () => { + const current = { + kind: 'assistant', + seq: 5, + time: 5_000, + turn: 2, + step: 1, + blocks: [{ kind: 'text', text: 'stable rewind response' }], + } as unknown as ConversationSnapshot['nodes'][number] + const snapshot = (id: number) => historySnapshot([current], { + contexts: [{ + id, + origin: 'rewind' as const, + originSeq: 4, + nodes: [current], + }], + }) + const store = createSnapshotStore(snapshot(1)) + render( + <TrajectoryView + {...standaloneProps([])} + {...standaloneDuration()} + useHistory={bindSnapshotSelector(store)} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} + />, + ) + const row = screen.getByRole('row', { name: /stable rewind response/ }) + fireEvent.click(row) + expect(row.getAttribute('aria-selected')).toBe('true') + + act(() => { store.set(snapshot(2)) }) + + expect(screen.getByRole('row', { name: /stable rewind response/ }) + .getAttribute('aria-selected')).toBe('true') + }) + + it('keeps ledger and timeline selection on the same event after prepend', () => { + const older = { + kind: 'user', seq: 1, time: 1_000, + content: [{ type: 'text', text: 'older prompt' }], source: null, + } as unknown as ConversationSnapshot['nodes'][number] + const current = { + kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1, + blocks: [{ kind: 'text', text: 'selected current response' }], + } as unknown as ConversationSnapshot['nodes'][number] + const store = createSnapshotStore(historySnapshot([current])) + const view = render( + <TrajectoryView + {...standaloneProps([])} + {...standaloneDuration()} + useHistory={bindSnapshotSelector(store)} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} + />, + ) + fireEvent.click(screen.getByRole('row', { name: /selected current response/ })) + + act(() => { store.set(historySnapshot([older, current])) }) + + const row = screen.getByRole('row', { name: /selected current response/ }) + expect(row.getAttribute('aria-selected')).toBe('true') + const currentIndex = row.getAttribute('data-record-index') + expect(view.container.querySelector( + `[data-timeline-record-index="${currentIndex}"][data-current="true"]`, + )).toBeTruthy() + }) + it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => { const retained = { kind: 'user', seq: 1, time: 1_000, @@ -1108,7 +1252,8 @@ describe('TrajectoryView branches', () => { {...standaloneProps([])} {...standaloneDuration()} useHistory={bindSnapshotSelector(store)} - loadAllHistory={vi.fn(() => Promise.resolve())} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) diff --git a/packages/client/ui-trajectory/tests/virtual-rows.spec.ts b/packages/client/ui-trajectory/tests/virtual-rows.spec.ts new file mode 100644 index 0000000000..44c375e9d3 --- /dev/null +++ b/packages/client/ui-trajectory/tests/virtual-rows.spec.ts @@ -0,0 +1,95 @@ +/** Measurable virtual-row grouping and durable identity contracts. */ + +import { describe, expect, it } from 'vitest' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' +import { + groupTrajectoryVirtualRows, trajectoryVirtualRecordKey, + type VirtualizableTrajectoryRecord, +} from '../src/client/trajectory-virtual-rows.ts' + +function record( + index: number, + cell: Partial<TrajectoryCellProps> = {}, + collapsedSummaryKind?: 'turn' | 'assistant', +): VirtualizableTrajectoryRecord { + return { + cell: { + index, + kind: 'message', + text: `record ${index}`, + timeSeconds: 0, + ...cell, + }, + ...(collapsedSummaryKind === undefined ? {} : { collapsedSummaryKind }), + } +} + +describe('trajectory virtual rows', () => { + it('groups zero-height request boundaries with the following content row', () => { + const first = record(1, { requestOnly: true, sourceSeq: 10 }) + const second = record(2, { requestOnly: true, sourceSeq: 11 }) + const content = record(3, { sourceSeq: 12 }) + + expect(groupTrajectoryVirtualRows([first, second, content])).toEqual([{ + entries: [ + { logicalIndex: 0, record: first }, + { logicalIndex: 1, record: second }, + { logicalIndex: 2, record: content }, + ], + height: 30, + key: trajectoryVirtualRecordKey(content), + }]) + }) + + it('retains terminal request-boundary clearance as a measurable row', () => { + const content = record(1, { sourceSeq: 10 }) + const boundary = record(2, { requestOnly: true, sourceSeq: 11 }) + const rows = groupTrajectoryVirtualRows([content, boundary]) + + expect(rows).toHaveLength(2) + expect(rows[1]).toEqual({ + entries: [{ logicalIndex: 1, record: boundary }], + height: 9, + key: trajectoryVirtualRecordKey(boundary), + }) + }) + + it('uses the rendered collapsed-summary height', () => { + const summary = record(1, { sourceSeq: 10 }, 'turn') + + expect(groupTrajectoryVirtualRows([summary])[0]?.height).toBe(20) + }) + + it('keeps an existing row key stable when older history is prepended', () => { + const existing = record(2, { sourceSeq: 100 }) + const prepended = record(1, { sourceSeq: 10 }) + + const before = groupTrajectoryVirtualRows([existing])[0]?.key + const after = groupTrajectoryVirtualRows([prepended, existing])[1]?.key + + expect(after).toBe(before) + }) + + it('keeps the content key when a request boundary joins its row', () => { + const content = record(2, { sourceSeq: 100 }) + const boundary = record(1, { requestOnly: true, sourceSeq: 99 }) + + expect(groupTrajectoryVirtualRows([boundary, content])[0]?.key) + .toBe(groupTrajectoryVirtualRows([content])[0]?.key) + }) + + it('distinguishes a folded summary from its source record', () => { + const source = record(1, { sourceSeq: 10 }) + const summary = record(1, { sourceSeq: 10 }, 'assistant') + + expect(trajectoryVirtualRecordKey(summary)).not.toBe(trajectoryVirtualRecordKey(source)) + }) + + it('exposes a DOM-safe semantic key', () => { + const source = record(1, { callId: 'call with spaces/and?punctuation' }) + + expect(trajectoryVirtualRecordKey(source)).toBe( + 'message%00call%00call%20with%20spaces%2Fand%3Fpunctuation', + ) + }) +}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 374efd0f58..fee956b683 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 -README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab +README.md: bd7313b560e76378e4fff274c99bb976819aebae +README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 17105f9d70..bd7313b560 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,9 +12,9 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. -Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. +Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty. -Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. +Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. @@ -29,6 +29,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. -- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. -- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. +- **No Session deletion or unarchive control** — sessions can be archived, but archived sessions have no viewing or unarchive surface, and Workspace registration deletion does not delete Sessions. +- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. +- **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b40b946927..734a897b9c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,9 +12,9 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 -Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 +Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。 -两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 +两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。 共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 @@ -29,6 +29,6 @@ Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原 ## 已知限制与暂缓事项 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 -- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 -- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 +- **没有 Session 删除与取消归档控件**:会话可以归档,但已归档会话没有查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 +- **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 +- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,进程内部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index df810c4493..71148df7b6 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -65,8 +65,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 0b8841a3a4..6052b5075f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -228,7 +228,9 @@ - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - padding-bottom: 12px; + /* Clears the 72px bottom fade overlay: at scroll end the last row sits + above the gradient instead of under it. */ + padding-bottom: 48px; scrollbar-gutter: stable; } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 3607dc8640..fd4f31f78e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -78,14 +78,16 @@ function GroupByMenu({ groupBy, onPick, t }: { // be cut off at the header's bounds. portal anchor={( - <button - type="button" - className={clsx(css.iconButton, css.wide)} - aria-label={t('groupBy.label')} - onClick={() => { setOpen(v => !v) }} - > - <IconPersonalizationOutline16 /> - </button> + <Tooltip label={t('groupBy.label')} side="bottom" delayMs={500}> + <button + type="button" + className={clsx(css.iconButton, css.wide)} + aria-label={t('groupBy.label')} + onClick={() => { setOpen(v => !v) }} + > + <IconPersonalizationOutline16 /> + </button> + </Tooltip> )} /> ) @@ -306,6 +308,7 @@ function SearchResults({ result={result} currentId={list.current} onOpen={open} + t={t} /> ))} </div> @@ -550,7 +553,7 @@ export function WorkspaceBrowser({ picking affordance has nothing to offer here: the region hides the button rather than leaving a dead one in the header. */} {directoryFlowAvailable && ( - <Tooltip label={t('workspace.add')} disabled={wide}> + <Tooltip label={t('workspace.add')} side="bottom" delayMs={500}> <button ref={wsPlusRef} type="button" diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 59574beed3..3af6a26629 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,7 +8,6 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -40,8 +39,8 @@ const NS = 'workspace' * the ui-sidebar / ui-conversation applies, whose activation order relative * to this one is NOT constrained: dshClient.inject edges are informational * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. + * provides a waitable service. apply therefore depends on each slot + * declaration through `slots.inject()` instead of assuming order. */ export const inject = ['slots', 'sessions', 'workspaces', 'locale'] @@ -103,36 +102,25 @@ export function apply(ctx: ClientContext): void { createWorkspace: input => ctx.workspaces.create(input), hooks: { directoryFlow: pickerFlowSource }, }) - // Declaration-aware registration (deferRegistration): each owner's - // declaring apply may activate after this one, and a register into an - // undeclared slot throws; the deferral also re-registers after an HMR - // collapse re-declares the slot. Each registration declares its own - // directory-flow child hole in the same call (declaration = render - // authorization, one table). - ctx.effect(() => { - const deferred = [ - deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () => - ctx.slots.register( - { - name: 'sidebar.workspaces', - children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, - store: createWorkspaceViewStore(), - inject: browserInjected, - locale: NS, - }, - WorkspaceBrowser, - )), - deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () => - ctx.slots.register( - { - name: 'conversation.hero.workspace', - children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, - inject: pickerInjected, - locale: NS, - }, - WorkspacePicker, - )), - ] - return () => { for (const entry of deferred) entry.dispose() } - }, 'ui-workspace: browser + picker registrations') + // Each registration declares its directory-flow child in the same call; + // slot injection follows both the owner and declaration HMR lifetimes. + ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register( + { + name: 'sidebar.workspaces', + children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, + store: createWorkspaceViewStore(), + inject: browserInjected, + locale: NS, + }, + WorkspaceBrowser, + )) + ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register( + { + name: 'conversation.hero.workspace', + children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, + inject: pickerInjected, + locale: NS, + }, + WorkspacePicker, + )) } diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index e64127a70d..b9e06a6ae2 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -47,6 +47,8 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', + 'status.planReview': '计划待审', + 'status.waitingAnswer': '等待回答', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -105,6 +107,8 @@ export const en = { 'status.running': 'Running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', + 'status.planReview': 'Plan awaiting review', + 'status.waitingAnswer': 'Waiting for answer', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index e961919498..836325076b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -166,14 +166,29 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** Session status presentation; approval waiting outranks the underlying running state. */ -function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { - if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } +/* v8 ignore next 3 -- closed-union backstop; only reached if the status is forged */ +function assertNever(value: never): never { + throw new Error(`unknown pending interaction: ${String(value)}`) +} + +/** Session status presentation; pending user interaction outranks the running state. */ +function sessionStatus( + node: Pick<SessionNode, 'pendingInteraction' | 'running'>, + t: RowTranslate, +): { state: StateDotState; label: string } { + switch (node.pendingInteraction) { + case 'approval': return { state: 'warning', label: t('status.waitingApproval') } + case 'plan-review': return { state: 'warning', label: t('status.planReview') } + case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case undefined: break + /* v8 ignore next -- closed PendingInteractionStatus union */ + default: return assertNever(node.pendingInteraction) + } if (node.running) return { state: 'ongoing', label: t('status.running') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and approval/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -215,14 +230,17 @@ export interface RowDragProps { * @param props.result - merged local/content search row. * @param props.currentId - selected session id. * @param props.onOpen - open the selected session. + * @param props.t - Workspace-browser translation seat. * @returns the result button. */ -export function SearchResultItem({ result, currentId, onOpen }: { +export function SearchResultItem({ result, currentId, onOpen, t }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void + t: RowTranslate }) { const selected = result.id === currentId + const status = sessionStatus(result, t) return ( <button type="button" @@ -232,7 +250,14 @@ export function SearchResultItem({ result, currentId, onOpen }: { onClick={() => { onOpen(result.id) }} > <span className={css.searchResultHeading}> - <span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span> + <span className={css.slot}> + {status.state !== 'done' && ( + <> + <StateDot state={status.state} /> + <span className={css.visuallyHidden}>{status.label}</span> + </> + )} + </span> <span className={css.searchResultTitle}>{result.title}</span> </span> <span className={css.searchResultWorkspace}>{result.workspace}</span> @@ -250,7 +275,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | } /** - * One top-level 34px session row: status dot (approval waiting outranks + * One top-level 34px session row: status dot (pending user interaction outranks * running), title, relative time, and the row actions menu. * @param props.node - derived session node. * @param props.currentId - selected session id (row highlight). diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 8c72608eaf..1a9f42504c 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -4,7 +4,8 @@ * remains visible. */ import type { - SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView, + PendingInteractionStatus, SessionId, SessionListState, SessionSearchResultItem, SessionSummary, + WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' /** Group key for Sessions outside every Workspace. */ @@ -20,8 +21,8 @@ export interface SessionNode { title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean - /** The runtime Session list reports a pending approval request for this Session. */ - waitingApproval: boolean + /** The runtime Session list reports an interaction awaiting this user. */ + pendingInteraction?: PendingInteractionStatus running: boolean updatedAt: number } @@ -50,6 +51,8 @@ export interface SearchResultNode { id: SessionId title: string workspace: string + /** The runtime Session list reports an interaction awaiting this user. */ + pendingInteraction?: PendingInteractionStatus running: boolean snippet?: string } @@ -171,9 +174,9 @@ function sessionNode(s: SessionSummary): SessionNode { id: s.id, title: sessionTitle(s), blank: s.blank, - waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, + ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), } } @@ -324,6 +327,9 @@ export function deriveSearchResults( title: sessionTitle(summary), workspace: labelOf(summary), running: summary.running, + ...(summary.pendingInteraction === undefined + ? {} + : { pendingInteraction: summary.pendingInteraction }), ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 1fdfbed0b9..1f5387cf43 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -66,16 +66,34 @@ describe('workspace browser rows', () => { running: true, snippet: 'matching message excerpt', } - render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />) + render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />) const row = screen.getByRole('treeitem') expect(row.getAttribute('aria-selected')).toBe('true') expect(screen.getByText('Workspace context')).toBeTruthy() expect(screen.getByText('matching message excerpt')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeTruthy() + expect(screen.getByText('进行中')).toBeTruthy() expect(row.hasAttribute('draggable')).toBe(false) fireEvent.click(row) expect(onOpen).toHaveBeenCalledWith(result.id) }) + it.each([ + ['approval', '等待审批'], + ['plan-review', '计划待审'], + ['question', '等待回答'], + ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => { + const result: SearchResultNode = { + id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', + pendingInteraction, running: true, + } + render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />) + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByText(label)).toBeTruthy() + }) + it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() const onCreate = vi.fn() @@ -96,7 +114,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, } const onOpen = vi.fn() render( @@ -180,7 +198,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, } render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) @@ -206,7 +224,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, } render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen} onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) @@ -239,7 +257,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, } render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) @@ -261,19 +279,23 @@ describe('workspace browser rows', () => { } }) - it('shows approval waiting as warning ahead of the running state', () => { + it.each([ + ['approval', '等待审批'], + ['plan-review', '计划待审'], + ['question', '等待回答'], + ] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('approval'), title: 'Needs approval', blank: false, - waitingApproval: true, running: true, updatedAt: 0, + id: sid(pendingInteraction), title: 'Needs input', blank: false, + pendingInteraction, running: true, updatedAt: 0, } const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() - expect(screen.getByText('等待审批')).toBeTruthy() + expect(screen.getByText(label)).toBeTruthy() view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0} onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) @@ -281,7 +303,7 @@ describe('workspace browser rows', () => { fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getAllByText('等待审批')).toHaveLength(2) + expect(screen.getAllByText(label)).toHaveLength(2) expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) } finally { vi.useRealTimers() @@ -292,7 +314,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, } render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) @@ -307,7 +329,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 249c3629a2..a15fffa3d8 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -11,7 +11,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), @@ -38,12 +38,12 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) - it('projects approval-waiting state into grouped and flat rows', () => { - const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + it('projects pending-interaction state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true } const sessions = list(awaiting) const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project'])) - expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) - expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) + expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) }) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { @@ -225,6 +225,7 @@ describe('deriveSearchResults', () => { it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => { const titleHit = summary('title-hit', 30, '/projects/a') titleHit.displayTitle = 'Needle title' + titleHit.pendingInteraction = 'plan-review' const workspaceHit = summary('workspace-hit', 20, '/projects/b') workspaceHit.displayTitle = 'Ordinary title' const contentHit = summary('content-hit', 10, '/projects/c') @@ -257,6 +258,7 @@ describe('deriveSearchResults', () => { title: 'Needle title', workspace: 'Alpha', running: false, + pendingInteraction: 'plan-review', snippet: 'title session body excerpt', }, { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 02f559474f..6c46275894 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -22,7 +22,7 @@ const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides, + id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, }) const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/client/web-react/README.i18n.yaml b/packages/client/web-react/README.i18n.yaml index 30a8d7cee1..72afe765f0 100644 --- a/packages/client/web-react/README.i18n.yaml +++ b/packages/client/web-react/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/web-react/README.md -README.md: 7cc80f22bd5527838288d11c819e81b7ec4d17c4 -README.zh.md: 855417ff357b78b05fa54946c6cf84fafaa5180e +README.md: 8f1a525af1e249282f2cc473e7f9652f68f914a8 +README.zh.md: 55ed3a0fec4bfba4c4934db8728bc6fa23a7321a diff --git a/packages/client/web-react/README.md b/packages/client/web-react/README.md index 7cc80f22bd..8f1a525af1 100644 --- a/packages/client/web-react/README.md +++ b/packages/client/web-react/README.md @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. - **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; the engine hand-rolls persistence instead (see `attachPersistence`). - **`UseSession` is deliberately wide (`object` snapshot)** — the dependency direction (runtime → web-react, never the reverse) keeps the real `ConversationSnapshot` type out of reach; session-slot consumers narrow once at their boundary. -- **renderSlot is the single P-I form** — no Suspense, no per-entry lazy loading; the progressive-rendering surface returns with its own project. +- **`renderSlot` is the only rendering form** — there is no Suspense integration or per-entry lazy loading. diff --git a/packages/client/web-react/README.zh.md b/packages/client/web-react/README.zh.md index 855417ff35..55ed3a0fec 100644 --- a/packages/client/web-react/README.zh.md +++ b/packages/client/web-react/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -slot 终端设计的外壳侧 React 胶水:createSlotRenderer(外壳安装到运行时 SlotsService 的 SlotRenderer 实现)、SessionProvider(由框架接入的 render prop,也作为标准 seat 注入到声明会话 scope 子 slot 的配置项)、bindSnapshotSelector(唯一的钩子构造器:主机与引擎只传递裸 observable source;每个钩子在此绑定,并按 source 缓存)、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector,只挂载被选中的配置项,其 select 返回值以 `matched` 加入 props;`renderSlotChain` 绑定与 `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于运行时(store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包(package)。 +slot 终端设计的外壳侧 React 胶水:createSlotRenderer(外壳安装到运行时 SlotsService 的 SlotRenderer 实现)、SessionProvider(由框架接入的 render prop,也作为标准 seat 注入到声明会话 scope 子 slot 的配置项)、bindSnapshotSelector(唯一的钩子构造器:主机与引擎只传递裸 observable source;每个钩子在此绑定,并按 source 缓存)、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector,只挂载被选中的配置项,其 select 返回值以 `matched` 加入 props;`renderSlotChain` 绑定与 `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于运行时(store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包。 ## 模型体验 @@ -16,4 +16,4 @@ slot 终端设计的外壳侧 React 胶水:createSlotRenderer(外壳安装 - **persist 中间件会损坏原始值状态 store**:保存时它会对状态执行对象展开,因此 `SnapshotStore<string>` 往返后会变成字符映射;引擎改为自行实现持久化(见 `attachPersistence`)。 - **`UseSession` 有意保持宽泛(`object` 快照)**:依赖方向(runtime → web-react,绝不反向)使真实 `ConversationSnapshot` 类型不可访问;会话 slot 消费方在其边界处缩窄一次。 -- **renderSlot 是唯一的 P-I 形式**:没有 Suspense,也没有逐配置项惰性加载;渐进式渲染能力将在其独立项目中恢复。 +- **`renderSlot` 是唯一的渲染形式**:没有 Suspense 集成或逐配置项惰性加载。 diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 2d5c08b53a..b5e242a00e 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -36,8 +36,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 323f244b1e..9b652b3110 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/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/web/README.md -README.md: b8b03dcb58442116cc01a2ff4c30e266e3233ee9 -README.zh.md: 280ec52602321367715ae2a71c22cff265908299 +README.md: 74c481d573fb716e624e639c74b35c82e6894f63 +README.zh.md: 08c69665a00377ff4bb11eee90031a45b3901753 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index b8b03dcb58..74c481d573 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -8,7 +8,7 @@ Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin pack `PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections. -The optional `seams` parameter forwards the module system's `loadBundle` transport override (`BootSeams`); production callers omit it — it exists for test environments where external `<script>` execution cannot reach the page context (jsdom). +The optional `seams` parameter forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it. The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix. @@ -23,4 +23,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project). -- **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item. +- **Narrow-window shell behavior lacks an assembled walkthrough** — ui-layout implements the concession chain, but this package has no shell-level narrow-viewport acceptance case. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index 280ec52602..08c69665a0 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -8,7 +8,7 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(w `PLATFORM_MODULES`(src/platform.ts)是共享模块表层的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。 -可选 `seams` 参数会转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于外部 `<script>` 执行无法到达页面上下文的测试环境(jsdom)。 +可选 `seams` 参数会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。 外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。 @@ -23,4 +23,4 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(w ## 已知限制与暂缓事项 - **有意采用一次性渲染**:UI 等待启动 settle;只要一个配置项失败,加载页面就会保留并逐项显示醒目的报告,不提供部分可用性(渐进式渲染将作为独立项目恢复)。 -- **窄窗口验收暂缓**:ui-layout 已实现让步链,但外壳级窄视口演练是 P-II 验收项。 +- **窄窗口外壳行为缺少组装后演练**:ui-layout 已实现让步链,但该包没有外壳级窄 viewport 验收用例。 diff --git a/packages/client/web/package.json b/packages/client/web/package.json index b235e2accb..847d126386 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -47,8 +47,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/web/tests/base-styles.spec.ts b/packages/client/web/tests/base-styles.spec.ts index d87921cede..ecea9e9f2c 100644 --- a/packages/client/web/tests/base-styles.spec.ts +++ b/packages/client/web/tests/base-styles.spec.ts @@ -10,6 +10,9 @@ import { describe, expect, it } from 'vitest' const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme' const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8') +const themeManifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../../ui-theme/package.json', import.meta.url)), 'utf8'), +) as { exports: Record<string, string>; files: string[] } /** * Import specifiers of the sheet, in source order. Quote style and surrounding @@ -24,9 +27,9 @@ function importOrder(css: string): string[] { } /** - * Resolve a `<package>/styles/<file>` specifier to its path in the workspace. - * The theme package maps `./styles/*` to `./src/styles/*`, so the sheets stay - * on the source plane rather than needing a build. + * Resolve a `<package>/styles/<file>` specifier to its source path for a + * clean-tree test. The package build copies these sheets to their public + * `lib/styles` export. * @param specifier - import specifier from base.css. * @returns absolute path of the file the specifier names. */ @@ -38,6 +41,11 @@ function resolveThemeSheet(specifier: string): string { const imports = importOrder(baseCss) describe('web shell base.css', () => { + it('publishes theme sheets from the built artifact plane', () => { + expect(themeManifest.exports['./styles/*']).toBe('./lib/styles/*') + expect(themeManifest.files).toContain('lib/styles') + }) + it('imports every sheet from the theme package and each one exists', () => { expect(imports.length).toBeGreaterThan(0) for (const specifier of imports) { diff --git a/packages/code-runtime/README.i18n.yaml b/packages/code-runtime/README.i18n.yaml index a914fa79e3..a04f1985c2 100644 --- a/packages/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/README.md -README.md: dbe6b37ffa01d07c6902672a06ebf6f88548ff99 -README.zh.md: f97bc091839bf7660a4a8c5ed506037d6b63eb3f +README.md: 2d32a05071efdfa05c336211196bb769ed5a5fc7 +README.zh.md: 62c9a395ac3cf2b5cd55455ab5a273a1e276f6b7 diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index dbe6b37ffa..2d32a05071 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -6,7 +6,7 @@ The code-execution capability seam (see [capability seams](../../.agents/notes/i | Package | Role | ctx key | |---|---|---| -| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` | -| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend: fresh worker per run, TypeScript via host-side type-strip (annotations advisory, never type-checked), port-bridged bindings, budget/heap containment | registers `ctx.codeRuntime` | +| [`code-runtime/`](code-runtime/README.md) | Code-execution seam and shared vocabulary | `ctx.codeRuntime` | +| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend | registers `ctx.codeRuntime` | -The interface lives at `code-runtime/code-runtime/`; the shipped backend at `code-runtime/code-runtime-worker/`. Backends differ by execution substrate (worker thread, process, container) and by source language — both readonly descriptors on the service — and register `ctx.codeRuntime` without touching the interface or its consumer; that split is what makes a hardened backend a drop-in later. +Backends register the seam without changing its consumer. The child READMEs own language, isolation, and execution-budget details. diff --git a/packages/code-runtime/README.zh.md b/packages/code-runtime/README.zh.md index f97bc09183..62c9a395ac 100644 --- a/packages/code-runtime/README.zh.md +++ b/packages/code-runtime/README.zh.md @@ -1,12 +1,12 @@ -# code-runtime/:代码执行能力家族 +# code-runtime/ — 代码执行能力家族 [English](README.md) | 中文 -代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品**包(package)。 +代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和生成的 TypeScript SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。 -| 包 | 职责 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| `code-runtime/` | 抽象代码执行 seam(接口 + 词汇) | `ctx.codeRuntime` | -| [`code-runtime-worker/`](code-runtime-worker/README.md) | worker 线程后端:每次运行使用全新 worker,由宿主侧剥离 TypeScript 类型(类型注解仅供参考,绝不执行类型检查)、端口桥接绑定、预算/堆限制 | 注册 `ctx.codeRuntime` | +| [`code-runtime/`](code-runtime/README.md) | 代码执行 seam 与共享词汇 | `ctx.codeRuntime` | +| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker 线程后端 | 注册 `ctx.codeRuntime` | -接口位于 `code-runtime/code-runtime/`,随附的后端位于 `code-runtime/code-runtime-worker/`。不同后端可以采用不同执行基底(worker 线程、进程、容器)与源语言;二者都是服务上的只读描述符。后端注册 `ctx.codeRuntime`,无需修改接口或消费方;正是这种拆分,使未来可以直接换入加固后端。 +后端在不改变消费方的情况下注册该 seam。子 README 负责语言、隔离和执行预算细节。 diff --git a/packages/code-runtime/code-runtime-worker/README.i18n.yaml b/packages/code-runtime/code-runtime-worker/README.i18n.yaml index 9a8eef9301..8d3568e82e 100644 --- a/packages/code-runtime/code-runtime-worker/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-worker/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/code-runtime/code-runtime-worker/README.md -README.md: 83c9a398970831e88cb3ef5d71d3f175da97d1f1 -README.zh.md: 3a4714fab55b85ffeb237d2a9fd0615ae4789bcf +README.md: 590b79dcd1bc322350060b55767b09c6305edacc +README.zh.md: 12c25f892bd20892cb47e593f16b6aadd9ffa84c diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 83c9a39897..590b79dcd1 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -32,7 +32,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at ## The worker entry, unbuilt and built -Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local JSON snapshotter is parity-tested against the session-owned canonical boundary; both sides flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). +Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local and session-owned JSON boundaries both flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. The repository-wide requirement to exercise this published entry path belongs to the [testing policy](../../../docs/testing.md). The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details. @@ -47,7 +47,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists. -- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts. +- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — amaro or sucrase are the named drop-in replacements if the relied-on behavior shifts. - **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config). - **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface. - **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output. diff --git a/packages/code-runtime/code-runtime-worker/README.zh.md b/packages/code-runtime/code-runtime-worker/README.zh.md index 3a4714fab5..12c25f892b 100644 --- a/packages/code-runtime/code-runtime-worker/README.zh.md +++ b/packages/code-runtime/code-runtime-worker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 worker 线程实现:`WorkerCodeRuntime` 会在每次运行中使用一个全新的 Node `worker_threads.Worker`,输入 TypeScript,由宿主侧剥离类型,通过消息端口桥接绑定,输出 `{ value, logs, error? }`。**这是隔离措施,而非安全边界**:其信任立场有意与 bash 等价(参见 [Code Mode Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 的 Trust posture 章节),但提供 bash 没有的隔离:独立 isolate、空环境、堆上限与强制终止。 +这是 [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 worker 线程实现:`WorkerCodeRuntime` 会在每次运行中使用一个全新的 Node `worker_threads.Worker`,输入 TypeScript,由宿主侧剥离类型,通过消息端口桥接绑定,输出 `{ value, logs, error? }`。**这是隔离措施,而非安全边界**:其信任立场有意与 bash 等价(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 的 Trust posture 章节),但提供 bash 没有的隔离:独立 isolate、空环境、堆上限与强制终止。 ## 配置 @@ -32,7 +32,7 @@ ## 未构建与已构建的 worker 入口 -源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包(package)尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFS)Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。 +源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地和会话自有的 JSON 边界都会在消息端口周围展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFS)Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。演练这个已发布入口路径的仓库级要求由[测试策略](../../../docs/testing.md)规定。 SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Config`。运行所用的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。 @@ -47,7 +47,7 @@ SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Confi ## 已知限制与暂缓事项 - **程序派生的 OS 进程在程序终止后仍会存活**:`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿进程清理属于部署职责。 -- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amaro/sucrase 是已经点名的直接替代品。 +- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:如依赖的行为发生变化,amaro 或 sucrase 是已经点名的直接替代品。 - **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。 - **程序获得一个含 5 个方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。 - **中间绑定值没有字节上限**:程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。 diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index e1e1d6a2f6..cc72bf1b12 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index 4b5476220b..8e45c6265b 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602 -README.zh.md: 9103e7490e2fccb071cd8a234b224bc17514253e +README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489 diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index 9103e7490e..22d0b120d7 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -4,7 +4,7 @@ 这是**代码执行 seam**:抽象的 `CodeRuntime` 服务(`ctx.codeRuntime`)只定义代码运行时做什么,即针对宿主提供的一组异步绑定运行一段模型编写的程序,并报告 `{ value, logs, error? }`,而不规定如何实现。 -此包(package)承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。这两项职责均由 [Code Mode Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有与工具有关的内容都留在消费方。 +此包承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。这两项职责均由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有与工具有关的内容都留在消费方。 ## 服务 API(`ctx.codeRuntime`) diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 2d59302ec9..f6e3a08ce1 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/README.i18n.yaml b/packages/compact/README.i18n.yaml index 92031652b7..17357bea7b 100644 --- a/packages/compact/README.i18n.yaml +++ b/packages/compact/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/compact/README.md -README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28 -README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b +README.md: 509ea2764250f42e492f787deba959a8dd9967b7 +README.zh.md: adc52690dbe9e904d8f24199cb78493dab3333a4 diff --git a/packages/compact/README.md b/packages/compact/README.md index aa9fa6d941..509ea27642 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -6,9 +6,9 @@ A compaction capability family (see [capability seams](../../.agents/notes/imple | Package | Role | ctx key | |---|---|---| -| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | -| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | -| `command-compact/` | Human `/compact` command over the backend-independent `compactNow()` seam | (registers on `ctx.commands`) | +| [`compact/`](compact/README.md) | Compaction seam and event vocabulary | `ctx.compact` | +| [`compact-basic/`](compact-basic/README.md) | Token-pressure and summarization backend | registers `ctx.compact` | +| [`compact-tool-result-prune/`](compact-tool-result-prune/README.md) | Optional model-free tool-result pruning | `ctx.toolResultPrune` | +| [`command-compact/`](command-compact/README.md) | Human compaction command | registers on `ctx.commands` | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, deterministic pruning at `compact/compact-tool-result-prune/`, and the command at `compact/command-compact/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, command, or automatic callers. +The backend, optional pruner, and human command compose through the seam; token measurement remains a separate LLM-family service. The [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) owns the dependency rationale. diff --git a/packages/compact/README.zh.md b/packages/compact/README.zh.md index e771eb4bc7..adc52690db 100644 --- a/packages/compact/README.zh.md +++ b/packages/compact/README.zh.md @@ -1,14 +1,14 @@ -# compact/:压缩能力家族 +# compact/ — 压缩能力家族 [English](README.md) | 中文 -一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包(package)。 +一个压缩(compaction)能力家族(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要后端、无模型工具结果修剪配套工具,以及用户命令适配器。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| -| `compact/` | 抽象压缩 seam(接口 + `compact/*` 事件 + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + 按 token 预算保留内容 + `llm.stream()` 摘要生成 | (注册 `ctx.compact`) | -| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` | -| `command-compact/` | 面向用户的 `/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands`) | +| [`compact/`](compact/README.md) | 压缩 seam 与事件词汇 | `ctx.compact` | +| [`compact-basic/`](compact-basic/README.md) | token 压力与摘要后端 | 注册 `ctx.compact` | +| [`compact-tool-result-prune/`](compact-tool-result-prune/README.md) | 可选的无模型工具结果修剪 | `ctx.toolResultPrune` | +| [`command-compact/`](command-compact/README.md) | 用户压缩命令 | 注册到 `ctx.commands` | -接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。 +后端、可选修剪器和用户命令通过该 seam 组合;token 测量仍是独立的 LLM(大语言模型)家族服务。[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)负责依赖关系的设计原理。 diff --git a/packages/compact/command-compact/README.i18n.yaml b/packages/compact/command-compact/README.i18n.yaml index eca38d67da..c39570db18 100644 --- a/packages/compact/command-compact/README.i18n.yaml +++ b/packages/compact/command-compact/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/compact/command-compact/README.md -README.md: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4 -README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb +README.md: a32a6aeb9957f0fd5f8cff58b1edbb9bc29a4e3d +README.zh.md: c678f522115d9b0fd414b2f290b3cb54ce690722 diff --git a/packages/compact/command-compact/README.md b/packages/compact/command-compact/README.md index 1445e76f83..a32a6aeb99 100644 --- a/packages/compact/command-compact/README.md +++ b/packages/compact/command-compact/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions. +Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions. ## Command contract @@ -41,7 +41,7 @@ The producer injects `commands` and `compact`. Mount the command registry, one b name: '@deepseek-ai/dsh-command-compact' ``` -The TUI example and CLI host mount it beside `compact-basic`. Automation surfaces that compose no command registry keep automatic compaction only. +The shipped `dsh` base mounts it beside `compact-basic`, and the Web client provides the command adapter. Automation surfaces that compose no command adapter keep automatic compaction only. ## Model Experience diff --git a/packages/compact/command-compact/README.zh.md b/packages/compact/command-compact/README.zh.md index 0fb306afb3..c678f52211 100644 --- a/packages/compact/command-compact/README.zh.md +++ b/packages/compact/command-compact/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。 +通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现并执行它,无需模型轮次。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。 ## 命令契约 @@ -41,7 +41,7 @@ busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞, name: '@deepseek-ai/dsh-command-compact' ``` -TUI 示例与 CLI host 将它挂载在 `compact-basic` 旁。未组合命令注册表的自动化接口只保留自动压缩。 +随附 `dsh` 基础配置将它挂载在 `compact-basic` 旁,Web 客户端提供命令适配器。未组合命令适配器的自动化接口只保留自动压缩。 ## 模型体验 diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 3b009b45d7..324051b188 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 991ad5b43a..2390833bff 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -27,6 +27,8 @@ function expectedFailure(error: ManualCompactionError): CommandResult { kind: 'error', text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.', } + case 'cancelled': + return { kind: 'error', text: 'Compaction cancelled.' } case 'changed': return { kind: 'error', diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index b0406beb47..71af9534e4 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -67,7 +67,7 @@ async function harness(): Promise<Harness> { await ctx.plugin(CommandService) const compact = new StubCompactService(ctx) const plugin = await ctx.plugin(commandCompact) - const session = new Session(SessionId('command-compact')) + const session = Session.create(SessionId('command-compact')) const agent = { session, status: 'idle', @@ -175,6 +175,7 @@ describe('/compact human command', () => { it.each([ ['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'], + ['cancelled', 'Compaction cancelled.'], ['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'], ['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'], ['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'], diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 1ebfac01db..bbd9bcfcb1 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -92,7 +92,7 @@ describe('command-compact real Loader composition', () => { }) await context.loader.await() - const session = new Session(SessionId('loader-command-compact')) + const session = Session.create(SessionId('loader-command-compact')) const agent = { session, status: 'idle', diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index 2d1e216e49..8c8b85f323 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md -README.md: 33a0a47346bed98ed0653d53d424ea7cd25c2a24 -README.zh.md: 603a3104592e7e6acbf54c67ea9616ed9ab8c7cc +README.md: 0c7b009255dc2d41dc81cf2c7ff745e02ef28b9a +README.zh.md: 4af584a059c99725882afd6206bdf9c984c7d4e3 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 33a0a47346..0c7b009255 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -17,7 +17,7 @@ This backend owns the compaction policy: - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`. +- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability. @@ -162,4 +162,3 @@ The replayed system prompt, tools, and shadowed-region messages match the conver - **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. - **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule. -- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index 603a310459..4af584a059 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -**基础压缩(compaction)后端**:`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV cache(可在 `llm/stream` 处拦截)。 +**基础压缩(compaction)后端**:`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache(可在 `llm/stream` 处拦截)。 -这是压缩能力的实现层。seam 见 [接口包(package)](../compact/README.md),设计见 [能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。 +这是压缩能力的实现层。seam 见 [接口包](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。 ## 拥有的职责 @@ -17,7 +17,7 @@ - **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 - **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。 - **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。 -- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。 +- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。 - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 - **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。 @@ -162,4 +162,3 @@ Rules: - **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。 - **`compactRegion` 要求存在未结束的轮次**:在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。 - **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。 -- **摘要调用没有 transcript 快照覆盖**:`dsh-llm-replay` 从 `assistant/chunk` 事件派生调用,因此这次不含分片的直接 `ctx.llm.stream()` 调用无法回放([seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中明确的暂缓回放基础设施)。 diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 6a93e249a3..2b0624e06e 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index ebef4648df..0bf76975ba 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -12,7 +12,7 @@ import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { @@ -143,28 +143,30 @@ export class BasicCompactService extends CompactService { ) } - ctx.on('agent/step', async ( + ctx.on('agent/pre-step', async ( agent: Agent, - _turn: number, - _step: number, - signal: AbortSignal, - ) => { - if (signal.aborted) return - try { - const result = await this.compactIfNeeded(agent, 'pressure', signal) - if (result !== null) logResult(result, 'step pressure') - } catch (error: unknown) { - if (error instanceof TargetPressureConfigError) { - if (this.warnedPressureConfigTargets.has(error.targetKey)) return - this.warnedPressureConfigTargets.add(error.targetKey) + _messages, + { signal }, + next, + ): Promise<PreStepDecision> => { + if (!signal.aborted) { + try { + const result = await this.compactIfNeeded(agent, 'pressure', signal) + if (result !== null) logResult(result, 'step pressure') + } catch (error: unknown) { + if (error instanceof TargetPressureConfigError) { + if (this.warnedPressureConfigTargets.has(error.targetKey)) return next() + this.warnedPressureConfigTargets.add(error.targetKey) + } + const message = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) } - const message = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) } + return next() }) - ctx.on('agent/settled', (agent) => { - this.overflowRetries.delete(agent) + ctx.on('agent/status', (agent, status) => { + if (status === 'idle') this.overflowRetries.delete(agent) }) // A successful response starts a fresh overflow-recovery sequence even @@ -177,15 +179,11 @@ export class BasicCompactService extends CompactService { ctx.on('agent/request-error', async ( agent, - _turn, - _step, - _error, - failure, - _priorFailures, - _retryPolicy, + context, signal, next, ) => { + const { failure } = context if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) @@ -367,46 +365,56 @@ export class BasicCompactService extends CompactService { * Force one useful idle-session compaction below the pressure threshold, and * resolve only after its standalone marker pair is durably checkpointed. * @param agent - idle agent whose next-turn admission this call reserves. - * @param signal - command-owned cancellation forwarded to summarization. + * @param signal - cancellation scoped to this compaction request. * @returns the committed result, or `null` when no safe useful range exists. */ - override async compactNow( - agent: Agent, - signal: AbortSignal, - ): Promise<CompactionResult | null> { + override compactNow(agent: Agent, signal: AbortSignal): Promise<CompactionResult | null> { signal.throwIfAborted() - const releaseTurnAdmission = agent.reserveTurnAdmission() - if (releaseTurnAdmission === undefined) { + try { + return agent.runMaintenance(async (agentSignal) => { + const operationSignal = AbortSignal.any([agentSignal, signal]) + try { + operationSignal.throwIfAborted() + const range = selectCompactableRange( + agent.session, + this.ctx.tokenMeter.measure(agent.session), + 0, + ) + if (range === null) return null + return await compactSurfaceRegion( + this.regionDependencies(), + agent.session, + range.start, + range.end, + agent, + { + owner: null, + stability: 'selected-span', + flush: async () => { + await this.ctx.sessions.flush(agent.session) + }, + }, + operationSignal, + ) + } catch (error: unknown) { + if (agentSignal.aborted && operationSignal.reason === agentSignal.reason) { + throw new ManualCompactionError( + 'cancelled', + 'manual compaction was cancelled', + { cause: error }, + ) + } + operationSignal.throwIfAborted() + throw error + } + }) + } catch (error: unknown) { throw new ManualCompactionError( 'busy', 'manual compaction requires an idle agent with no waking queued work', + { cause: error }, ) } - try { - const range = selectCompactableRange( - agent.session, - this.ctx.tokenMeter.measure(agent.session), - 0, - ) - if (range === null) return null - return await compactSurfaceRegion( - this.regionDependencies(), - agent.session, - range.start, - range.end, - agent, - { - owner: null, - stability: 'selected-span', - flush: async () => { - await this.ctx.sessions.flush(agent.session) - }, - }, - signal, - ) - } finally { - releaseTurnAdmission() - } } /** Bind the effective token meter and dynamically dispatched summarizer hook. */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 52245bbd5a..fddbaceb80 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -103,9 +103,9 @@ function promptInput(text: string): SummarizationInput { /** Closed two-message turns followed by one open turn for durable compaction events. */ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { - const session = new Session(SessionId(`conversation-${turns}`)) + const session = Session.create(SessionId(`conversation-${turns}`)) for (let turn = 1; turn <= turns; turn += 1) { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `${text} user ${turn}` }], source: { kind: 'user' }, @@ -134,16 +134,15 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { } session.append('turn/start', { turn: turns + 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) return session } function toolConversation(): Session { - const session = new Session(SessionId('tools')) + const session = Session.create(SessionId('tools')) for (let turn = 1; turn <= 3; turn += 1) { const callId = CallId(`call-${turn}`) - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `request ${turn} `.repeat(300) }], source: { kind: 'user' }, @@ -183,15 +182,15 @@ function toolConversation(): Session { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 4 }) return session } /** One closed routed tool step followed by an open turn for rewrite events. */ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session { - const session = new Session(SessionId(`oversized-tool-${chars}`)) + const session = Session.create(SessionId(`oversized-tool-${chars}`)) const callId = CallId('oversized') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) if (withCompactablePrompt) { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'older history '.repeat(200) }], @@ -228,7 +227,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) return session } @@ -485,8 +484,8 @@ describe('pressure measurement and retention', () => { it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => { const compact = service(compactConfig) - const session = new Session(SessionId('headerless')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('headerless')) + session.append('turn/start', { turn: 1 }) await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL)) .resolves.toBeNull() expect(compact.calls).toHaveLength(0) @@ -567,9 +566,9 @@ describe('pressure measurement and retention', () => { it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { const compact = service(compactConfig) - const session = new Session(SessionId('single-tool-pair')) + const session = Session.create(SessionId('single-tool-pair')) const callId = CallId('single-call') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, @@ -658,8 +657,8 @@ describe('pressure measurement and retention', () => { it('declines when envelope pressure is high but the surface has no compactable range', async () => { const compact = service(compactConfig) - const empty = new Session(SessionId('empty')) - empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const empty = Session.create(SessionId('empty')) + empty.append('turn/start', { turn: 1 }) empty.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, reason: 'initial', @@ -733,9 +732,9 @@ describe('pressure measurement and retention', () => { it('declines when rounding a cut would consume the only tool pair', () => { const ctx = createContext() - const session = new Session(SessionId('one-tool-pair')) + const session = Session.create(SessionId('one-tool-pair')) const callId = CallId('only') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, @@ -874,7 +873,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>') expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' }) - const replay = new Session(SessionId('replay'), [...session.events]) + const replay = Session.create(SessionId('replay'), [...session.events]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -956,7 +955,7 @@ describe('compaction region transaction', () => { it('rejects a session with no turn boundary at all', async () => { const compact = service() - const session = new Session(SessionId('turnless')) + const session = Session.create(SessionId('turnless')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' }, @@ -1076,8 +1075,8 @@ describe('compaction region transaction', () => { it('lets a model-independent custom summarizer compact without a conversation model', async () => { const compact = service() - const session = new Session(SessionId('model-less-region')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('model-less-region')) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'history '.repeat(100) }], source: { kind: 'user' }, @@ -1313,13 +1312,13 @@ describe('default one-shot summarizer', () => { await ctx.plugin(LlmService) void new TokenMeterService(ctx) const compact = new ExposedCompactService(ctx, { auto: false }) - await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less'))))) + await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) it('uses a complete AgentOptions target when no durable route exists', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) - const session = new Session(SessionId('headerless-summary')) + const session = Session.create(SessionId('headerless-summary')) await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({ provider: MODEL, @@ -1335,7 +1334,7 @@ describe('default one-shot summarizer', () => { ])('rejects incomplete AgentOptions target %#', async (options) => { const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) const owner = { - session: new Session(SessionId(`incomplete-${String(options.model)}`)), + session: Session.create(SessionId(`incomplete-${String(options.model)}`)), options, } as Agent await expect(compact.runSummarize(promptInput('history'), owner)) @@ -1371,8 +1370,11 @@ describe('default one-shot summarizer', () => { }) describe('automatic listener and loader composition', () => { - function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> { - return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal) + function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { + return agentEvents(ctx, owner).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) } function recover( @@ -1385,7 +1387,10 @@ describe('automatic listener and loader composition', () => { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( - 'agent/request-error', turn, 1, error, failure, [], undefined, signal, next, + 'agent/request-error', + { turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, + signal, + next, ).then(action => action?.kind === 'retry') } @@ -1393,23 +1398,23 @@ describe('automatic listener and loader composition', () => { return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE }) } - it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => { + it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const pressured = conversation(4) - await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) + await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) const small = conversation(1) - await postStep(ctx, agent(small, MODEL)) + await preStep(ctx, agent(small, MODEL)) expect(small.events.some(event => event.type === 'compact/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) - it('skips post-step pressure when the step signal is already aborted', async () => { + it('skips pre-step pressure when the step signal is already aborted', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, @@ -1418,8 +1423,8 @@ describe('automatic listener and loader composition', () => { const pressured = conversation(4) const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded') - await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) - .resolves.toBeUndefined() + await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) + .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false) @@ -1436,7 +1441,7 @@ describe('automatic listener and loader composition', () => { compact.error = 'temporary failure' const session = conversation(4) - await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() + await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] }) expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) @@ -1456,8 +1461,8 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), @@ -1474,8 +1479,8 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), @@ -1698,10 +1703,9 @@ describe('automatic listener and loader composition', () => { it('delegates canonical overflow when no durable routed target exists', async () => { const ctx = createContext() void new TestCompactService(ctx) - const session = new Session(SessionId('headerless-overflow')) + const session = Session.create(SessionId('headerless-overflow')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false) @@ -1759,7 +1763,7 @@ describe('automatic listener and loader composition', () => { retainTokens: 180, }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) const summaries = session.events.filter(event => event.type === 'compact/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) @@ -1774,7 +1778,7 @@ describe('automatic listener and loader composition', () => { retainTokens: 180, }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1804,7 +1808,7 @@ describe('automatic listener and loader composition', () => { await fiber.dispose() const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e6f98817c3..132135bd48 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -185,12 +185,11 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function overflowHistorySeed(): SessionEvent[] { - const session = new Session(SessionId('overflow-history-seed')) + const session = Session.create(SessionId('overflow-history-seed')) for (let turn = 1; turn <= 2; turn += 1) { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], @@ -307,7 +306,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () describe('context-overflow recovery across the real loop and compact-basic', () => { it.each(['thrown', 'in-band'] as const)( - 'force-compacts a %s overflow between failed and retry steps', + 'force-compacts a %s overflow within the retried step', async (delivery) => { const ctx = new Context() const adapter = new OverflowRecoveryAdapter(delivery) @@ -353,18 +352,12 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(retry).not.toContain('OLD HISTORY SENTINEL') const events = [...agent.session.events] - const failedStepEnd = events.find(event => + const stepStart = events.find(event => + event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, + )! + const stepEnd = events.find(event => event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1, )! - const failedEnd = events.find(event => - event.type === 'turn/end' && event.data.turn === 3, - )! - const retryStart = events.find(event => - event.type === 'turn/start' && event.data.turn === 4, - )! - const retryStep = events.find(event => - event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1, - )! const compaction = events.filter(event => event.type === 'compact/start' || event.type === 'compact/summary' @@ -375,11 +368,13 @@ describe('context-overflow recovery across the real loop and compact-basic', () 'compact/summary', 'compact/end', ]) - expect(retryStart.seq).toBeGreaterThan(failedEnd.seq) expect(compaction.every(event => - event.seq > failedStepEnd.seq && event.seq < failedEnd.seq, + event.seq > stepStart.seq && event.seq < stepEnd.seq, )).toBe(true) - expect(retryStep.seq).toBeGreaterThan(retryStart.seq) + expect(events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + .toEqual([3]) + expect(events.filter(event => event.type === 'step/start' && event.data.turn === 3)) + .toHaveLength(1) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -419,9 +414,9 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) - .toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn)) - .toEqual([3, 4, 5]) + .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) + expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + .toEqual([3]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index dc75828f2e..162c44efd4 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -77,7 +77,7 @@ describe('real Loader composition', () => { expect(unloaded).toEqual([]) expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) - expect((loaded.compact as BasicCompactService).config).toMatchObject({ + expect((loaded.compact as unknown as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, retainRatio: 0.125, auto: false, diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 6ce3e18f2a..d2dcda461e 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -112,7 +112,7 @@ async function loopHarness(): Promise<LoopHarness> { const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL }) const log: string[] = [] ctx.on('session/event', (_session, event) => { - if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`) + if (event.type === 'turn/start') log.push('turn/start') if (event.type === 'turn/end') log.push('turn/end') if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`) if (event.type === 'compact/summary') log.push('compact/summary') @@ -141,11 +141,14 @@ function derivedText(session: Session): string[] { } /** Await one classified manual-compaction rejection. */ -async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> { - const caught: unknown = await operation.then( - (value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) }, - (error: unknown) => error, - ) +async function rejection(operation: Promise<unknown> | (() => Promise<unknown>)): Promise<ManualCompactionError> { + let caught: unknown + try { + const value = await (typeof operation === 'function' ? operation() : operation) + throw new Error(`expected a rejection, resolved with ${String(value)}`) + } catch (error: unknown) { + caught = error + } if (!(caught instanceof ManualCompactionError)) { throw new Error(`expected a ManualCompactionError, got ${String(caught)}`) } @@ -166,10 +169,10 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } { /** A closed-tail session with compactable exchanges and no live agent. */ function closedConversation(turns = 2, lastTurnNumber = turns): Session { - const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`)) + const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`)) for (let index = 1; index <= turns; index += 1) { const turn = index === turns ? lastTurnNumber : index - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `${PROMPT} ${turn}` }], source: { kind: 'user' }, @@ -195,15 +198,20 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session { return session } -/** A fake idle agent whose admission reservation is scripted per test. */ +/** A fake idle agent whose maintenance claim is scripted per test. */ function fakeAgent( session: Session, reserve: () => (() => void) | undefined, + maintenanceSignal = new AbortController().signal, ): Agent { return { session, options: { provider: MODEL, model: MODEL }, - reserveTurnAdmission: reserve, + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> { + const release = reserve() + if (release === undefined) throw new Error('agent already has active work') + return task(maintenanceSignal).finally(release) + }, } as unknown as Agent } @@ -256,7 +264,7 @@ describe('compactNow through the real loop', () => { const summary = log.indexOf('compact/summary') const end = log.indexOf('compact/end:null') const flush = log.indexOf('flush') - const nextTurn = log.indexOf('turn/start:message') + const nextTurn = log.indexOf('turn/start') expect(start).toBeLessThan(summary) expect(summary).toBeLessThan(end) expect(end).toBeLessThan(flush) @@ -270,7 +278,7 @@ describe('compactNow through the real loop', () => { expect(second.some(text => text.includes(PROMPT))).toBe(false) }) - it('keeps context injected during summarization between the markers and after the checkpoint', async () => { + it('keeps context injected during summarization pending for the next step', async () => { const harness = await loopHarness() const { agent, compact } = harness await seedHistory(harness) @@ -285,18 +293,22 @@ describe('compactNow through the real loop', () => { expect(result).not.toBeNull() const start = agent.session.events.findLast(event => event.type === 'compact/start') - const injected = agent.session.events.findLast(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'test') + const injected = agent.inbox.nextStep.find(message => + message.source.kind === 'plugin' && message.source.plugin === 'test') const end = agent.session.events.findLast(event => event.type === 'compact/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() - expect(start!.seq).toBeLessThan(injected!.seq) - expect(injected!.seq).toBeLessThan(end!.seq) - expect(result?.shadowedSeqs).not.toContain(injected?.seq) + expect(agent.session.events.some(event => event.type === 'user/message' + && event.data.id === injected?.id)).toBe(false) + + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'after compaction' }], + source: { kind: 'user' }, + })) + await agent.whenIdle() const messages = derivedText(agent.session) expect(messages[0]).toContain('checkpoint') - expect(messages.at(-1)).toContain('INJECTED CONTEXT') expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1) }) @@ -334,7 +346,7 @@ describe('compactNow through the real loop', () => { content: [{ type: 'text', text: 'first in line' }], source: { kind: 'user' }, })) - expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy') expect(compact.calls).toHaveLength(0) await agent.whenIdle() @@ -368,7 +380,7 @@ describe('compactNow through the real loop', () => { describe('compactNow transaction and failure classification', () => { it('returns null without writing a bracket for history that cannot be compacted', async () => { const { compact } = detachedService() - const session = new Session(SessionId('empty')) + const session = Session.create(SessionId('empty')) let released = 0 const agent = fakeAgent(session, () => () => { released += 1 }) @@ -400,7 +412,7 @@ describe('compactNow transaction and failure classification', () => { session.append('compact/start', { turn: null }) const agent = fakeAgent(session, () => () => undefined) - const error = await rejection(compact.compactNow(agent, SIGNAL)) + const error = await rejection(() => compact.compactNow(agent, SIGNAL)) expect(error.code).toBe('busy') expect(error.message).toContain('compaction lock is already active') expect(compact.calls).toHaveLength(0) @@ -410,7 +422,7 @@ describe('compactNow transaction and failure classification', () => { const { compact } = detachedService() const original = closedConversation(2) original.append('compact/start', { turn: null }) - const reloaded = new Session(SessionId('stale-orphan'), [...original.events]) + const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') const orphan = reloaded.events.find(event => event.type === 'compact/start') const agent = fakeAgent(reloaded, () => () => undefined) @@ -424,9 +436,9 @@ describe('compactNow transaction and failure classification', () => { const { compact } = detachedService() const original = closedConversation(2) original.append('compact/start', { turn: null }) - original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events]) + const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -436,7 +448,7 @@ describe('compactNow transaction and failure classification', () => { it('refuses an open turn in the log', async () => { const { compact } = detachedService() const session = closedConversation(2) - session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 3 }) const agent = fakeAgent(session, () => () => undefined) const error = await rejection(compact.compactNow(agent, SIGNAL)) @@ -448,7 +460,7 @@ describe('compactNow transaction and failure classification', () => { const { compact } = detachedService() const agent = fakeAgent(closedConversation(2), () => undefined) - expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy') expect(compact.calls).toHaveLength(0) }) @@ -638,7 +650,7 @@ describe('compactNow transaction and failure classification', () => { it('compacts a session with no durable turn boundary without creating one', async () => { const { compact } = detachedService() - const session = new Session(SessionId('turnless')) + const session = Session.create(SessionId('turnless')) for (const text of [PROMPT, 'recent tail']) { session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], @@ -671,7 +683,7 @@ describe('compactNow transaction and failure classification', () => { it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => { const cases = [ { name: 'busy', session: closedConversation(2), release: undefined }, - { name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined }, + { name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined }, { name: 'compactable', session: closedConversation(2, 9), release: () => undefined }, ] as const @@ -685,7 +697,13 @@ describe('compactNow transaction and failure classification', () => { const controller = new AbortController() controller.abort(reason) - await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + let thrown: unknown + try { + void compact.compactNow(agent, controller.signal) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBe(reason) expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) @@ -713,6 +731,21 @@ describe('compactNow transaction and failure classification', () => { .toContain('summarizer aborted') }) + it('classifies agent cancellation during maintenance as an expected cancellation', async () => { + const { compact } = detachedService() + const controller = new AbortController() + const reason = new Error('agent cancelled maintenance') + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined, controller.signal) + compact.duringSummary = () => { controller.abort(reason) } + compact.error = new Error('summarizer observed cancellation') + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + + expect(error.code).toBe('cancelled') + expect(error.cause).toBe(reason) + }) + it('aborts before committing when cancellation lands after summarization', async () => { const { compact } = detachedService() const controller = new AbortController() @@ -814,7 +847,7 @@ describe('compactNow transaction and failure classification', () => { it('excludes a manual request while an explicit region compaction runs', async () => { const { compact } = detachedService() const session = closedConversation(3) - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 4 }) const agent = fakeAgent(session, () => () => undefined) const gate = deferred() compact.gate = gate.promise diff --git a/packages/compact/compact-tool-result-prune/README.i18n.yaml b/packages/compact/compact-tool-result-prune/README.i18n.yaml index a9445fca25..78eb863c26 100644 --- a/packages/compact/compact-tool-result-prune/README.i18n.yaml +++ b/packages/compact/compact-tool-result-prune/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact-tool-result-prune/README.md README.md: edeba52b189b3cee5530faf7efc04043a326917f -README.zh.md: 1b42a9db5d3288c6610e431c57403858a238c07a +README.zh.md: abc19afa784ec1121430b57a9d90d22d12b89810 diff --git a/packages/compact/compact-tool-result-prune/README.zh.md b/packages/compact/compact-tool-result-prune/README.zh.md index 1b42a9db5d..abc19afa78 100644 --- a/packages/compact/compact-tool-result-prune/README.zh.md +++ b/packages/compact/compact-tool-result-prune/README.zh.md @@ -4,7 +4,7 @@ 可安全回放、不依赖模型的剪枝服务(`ctx.toolResultPrune`)。它会将超出预算的 `tool/result` 表层节点改写为长度受限的头部、固定省略标记和长度受限的尾部,同时在仅追加会话日志中保留完整原始事件。 -这是 [`dsh-compact-basic`](../compact-basic/README.md) 的具体配套服务,不是压缩(compaction)后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPrune')` 读取它,因此这两个包(package)仍可各自独立组合。 +这是 [`dsh-compact-basic`](../compact-basic/README.md) 的具体配套服务,不是压缩(compaction)后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPrune')` 读取它,因此这两个包仍可各自独立组合。 ## 服务 API diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 7cd9ff6b98..bc38eccdb0 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -21,15 +21,15 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -38,9 +38,11 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 0fbf7bcac0..f783cdd59d 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -9,6 +9,10 @@ import z from 'schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' +// Type-only: the `compact/*` SessionEventMap merges (the shadow-price event). +import type {} from '@deepseek-ai/dsh-compact' +// Type-only: the `ctx.tokenMeter` Context merge for the declared injection. +import type {} from '@deepseek-ai/dsh-token-meter' import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts' import type { PrunedEntry, @@ -38,6 +42,10 @@ interface SnapshotCandidate { /** Deterministic head/middle/tail pruning for current tool-result surface nodes. */ export class ToolResultPruneService extends Service { + // The token meter prices each shadowed node for its logged shadow-price + // event, so pruning genuinely requires the pricing capability. + static inject = ['tokenMeter'] + static Config: z<ToolResultPruneConfig> = z.object({ thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars), headChars: z.number().step(1).min(0).default(DEFAULTS.headChars), @@ -116,7 +124,10 @@ export class ToolResultPruneService extends Service { /** * Prune every over-budget tool result from one stable current-surface snapshot. * Each replacement preserves the complete event data except for `content`, - * and points at the shadowed node for durable provenance and replay. + * points at the shadowed node for durable provenance and replay, and is + * immediately preceded by a `compact/prune` shadow-price event pricing the + * shadowed node through the injected token meter, so pure consumers can + * subtract it without per-node state. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. * @throws when the session rejects a replacement; replacements committed @@ -145,6 +156,14 @@ export class ToolResultPruneService extends Service { content, }] as [typeof result], }) + // Shadow-price protocol: the metering event and its replacement are + // appended synchronously adjacent, so pure consumers subtract the + // shadowed node's heuristic price without retaining per-node state. + session.append('compact/prune', { + shadowedRange: { start: seq, end: seq }, + shadowedSeqs: [seq], + shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message), + }) const replacement = session.append('tool/result', { ...event.data, message, diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index db4c29ebdb..fbc4b840c9 100644 --- a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined @@ -23,6 +24,7 @@ describe('compact-tool-result-prune real Loader composition', () => { root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', @@ -38,10 +40,9 @@ describe('compact-tool-result-prune real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') { - throw new Error(`unexpected Loader import: ${specifier}`) - } - return ToolResultPruneService + if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeterService + if (specifier === '@deepseek-ai/dsh-compact-tool-result-prune') return ToolResultPruneService + throw new Error(`unexpected Loader import: ${specifier}`) }, } as unknown as NonNullable<typeof context.loader.internal> await context.loader.create({ @@ -60,6 +61,9 @@ describe('compact-tool-result-prune real Loader composition', () => { it('rejects stale config after plugin schema normalization', async () => { context = new Context() + // Satisfy the declared injection first: config normalization runs in the + // service constructor, which a pending fiber never reaches. + await context.plugin(TokenMeterService) await expect(context.plugin(ToolResultPruneService, { maxChars: 100, } as never)).rejects.toThrow(/unknown key "maxChars"/) diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index aa8997179e..c5c7171c08 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService, { codePointLength, DEFAULTS, @@ -25,9 +26,16 @@ const SMALL: ToolResultPruneConfig = { } function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService { - return new ToolResultPruneService(new Context(), config) + const ctx = new Context() + // Service constructors self-register, so `ctx.tokenMeter` resolves for the + // shadow-price pricing without a full plugin boot. + void new TokenMeterService(ctx) + return new ToolResultPruneService(ctx, config) } +/** Pricing oracle mirroring the service's estimator for expectations. */ +const METER = new TokenMeterService(new Context()) + function appendToolStep( session: Session, turn: number, @@ -38,7 +46,6 @@ function appendToolStep( const callId = CallId(call) session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('step/start', { turn, step: 1 }) session.append('assistant/message', { @@ -153,7 +160,7 @@ describe('ToolResultPruneService content transform', () => { describe('ToolResultPruneService session transaction', () => { it('prunes a stable snapshot, preserves all data, and records provenance', () => { - const session = new Session(SessionId('preserve')) + const session = Session.create(SessionId('preserve')) const originalSeq = appendToolStep(session, 1, 'one', [{ type: 'text', text: 'x'.repeat(100), @@ -165,7 +172,6 @@ describe('ToolResultPruneService session transaction', () => { }) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const result = service().pruneSession(session) @@ -205,16 +211,27 @@ describe('ToolResultPruneService session transaction', () => { sourceEventSeqs: [originalSeq], }) expect(session.surface.nodes).not.toContain(originalSeq) + + // Shadow-price protocol: the metering event sits directly before the + // replacement and prices the shadowed node with the shared estimator. + if (original.type !== 'tool/result') throw new Error('original is not a tool/result') + expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + type: 'compact/prune', + data: { + shadowedRange: { start: originalSeq, end: originalSeq }, + shadowedSeqs: [originalSeq], + shadowedTokenCount: METER.estimateMessage(original.data.message), + }, + }) }) it('prunes multiple results, skips short ones, and converges in one pass', () => { - const session = new Session(SessionId('multiple')) + const session = Session.create(SessionId('multiple')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }]) appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }]) session.append('turn/start', { turn: 4, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const prune = service() const first = prune.pruneSession(session) @@ -227,14 +244,13 @@ describe('ToolResultPruneService session transaction', () => { }) it('replays to the identical pruned model messages', () => { - const session = new Session(SessionId('replay')) + const session = Session.create(SessionId('replay')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) service().pruneSession(session) - const replay = new Session(session.id, [...session.events]) + const replay = Session.create(session.id, [...session.events]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) @@ -244,13 +260,13 @@ describe('ToolResultPruneService session transaction', () => { await ctx.plugin(SessionStore) await ctx.plugin(InvariantService) await ctx.plugin(SessionInvariant) + await ctx.plugin(TokenMeterService) const prune = new ToolResultPruneService(ctx, SMALL) const session = ctx.sessions.create(SessionId('invariants')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(() => prune.pruneSession(session)).not.toThrow() }) diff --git a/packages/compact/compact-tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json index a6c2e5124b..be5cb5b66c 100644 --- a/packages/compact/compact-tool-result-prune/tsconfig.json +++ b/packages/compact/compact-tool-result-prune/tsconfig.json @@ -10,7 +10,9 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, + { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, + { "path": "../compact" }, { "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml index 340fa308f5..6463edbc33 100644 --- a/packages/compact/compact/README.i18n.yaml +++ b/packages/compact/compact/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/compact/compact/README.md -README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52 -README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839 +README.md: cd2404cda8d8702ca0400d3f23d9e1fe041495a1 +README.zh.md: ed6ccc630118dc1bd6d6ea761beefafadb4bd48f diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index cfb65f2a78..cd2404cda8 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an ## Surface contract -`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: +`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, and `tool/result` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md index e069bea9ef..ed6ccc6301 100644 --- a/packages/compact/compact/README.zh.md +++ b/packages/compact/compact/README.zh.md @@ -4,7 +4,7 @@ **压缩(compaction) seam**:抽象 `CompactService`(`ctx.compact`)定义压缩做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。 -这个包(package)是压缩能力的接口层,因此各项职责均可独立演进,也可独立替换: +这个包是压缩能力的接口层,因此各项职责均可独立演进,也可独立替换: | 包 | 职责 | |---|---| @@ -12,7 +12,7 @@ | `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 | | `@deepseek-ai/dsh-command-compact` | 面向用户的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 | -与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。 +与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。 ## 服务 API(`ctx.compact`) @@ -38,7 +38,7 @@ ## 表层契约 -`SurfaceEventType` 是封闭联合:只有 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` 可以携带 `surfaceOp`。因此 `compact/*` 事件**不能**出现在表层上。成功压缩改为: +`SurfaceEventType` 是封闭联合:只有 `user/message`、`assistant/message` 和 `tool/result` 可以携带 `surfaceOp`。因此 `compact/*` 事件**不能**出现在表层上。成功压缩改为: 1. 追加 `compact/start`(仅日志):获取锁; 2. 摘要该范围; diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index f0874990a3..100a3f56f8 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index ecc00597b2..e537639adc 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -22,7 +22,13 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi export type CompactionTrigger = 'pressure' | 'context-overflow' /** Expected failure classes for an explicit idle-session compaction request. */ -export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +export type ManualCompactionErrorCode = + | 'busy' + | 'cancelled' + | 'changed' + | 'summary' + | 'commit' + | 'persistence' /** * Expected manual-compaction failure suitable for a direct human-command result. @@ -59,7 +65,14 @@ export interface CompactAgentContext { * other compaction transactions. */ export interface ManualCompactAgentContext extends CompactAgentContext { - reserveTurnAdmission(): (() => void) | undefined + /** + * Run a non-turn maintenance operation only while the agent is idle, withholding later + * waking input until it settles. + * @param task - operation whose fulfillment or rejection is preserved, with an agent-owned cancellation signal. + * @throws synchronously when the agent is already active. + * @returns the task promise. + */ + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> } declare module 'cordis' { @@ -102,21 +115,22 @@ export abstract class CompactService extends Service { /** * Explicitly compact useful history even below automatic pressure thresholds. - * Implementations reserve idle turn admission synchronously before any - * asynchronous work, select a useful range without writing on a no-op, then + * Implementations synchronously start an idle task before any asynchronous + * work, select a useful range without writing on a no-op, then * append a standalone `compact/start` before summarization. That durable * marker is the compaction lock until one `compact/end` attempt. Later waking * prompts remain accepted in FIFO order and start only after the optional - * durability checkpoint and admission release. Context injected while the + * durability checkpoint and idle-task settlement. Context injected while the * summary runs may sit between the marker pair; only the selected span must * remain stable. * * @param agent - idle agent whose durable history should be compacted. - * @param signal - command-owned cancellation forwarded to summarization. + * @param signal - cancellation scoped to this compaction request. * @returns the compaction result, or `null` when no safe useful range exists. - * @throws {@link ManualCompactionError} for expected busy, changed-span, - * summarization/shrink, commit-stage, or persistence failures, and the exact - * abort reason when cancelled. Failed attempts remain visible in the log. + * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, + * changed-span, summarization/shrink, commit-stage, or persistence failures; + * an aborted request preserves its exact abort reason. Failed attempts remain + * visible in the log. */ abstract compactNow( agent: ManualCompactAgentContext, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 173366bc4b..4a856a6848 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -20,8 +20,11 @@ declare module '@deepseek-ai/dsh-session' { /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement - * is performed by a subsequent `user/message` event that shadows the - * compacted range. + * is performed by the immediately following `user/message` event that + * shadows the compacted range. That adjacency is contractual — the + * shadowed pricing fields are the replacement's shadow price, so a + * consumer may pair a replacement with the metering event directly + * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { summary: ContentBlock[] @@ -49,6 +52,23 @@ declare module '@deepseek-ai/dsh-session' { * matches `compact/start`; `error` records an unsuccessful attempt. */ 'compact/end': { turn: number | null; error?: string } + /** + * Shadow price of one model-free prune replacement — log-only, no + * surfaceOp. The shared shadow-price protocol: a surface `replace` event + * is priced by the metering event immediately before it (`compact/summary` + * for a summarizing compaction, this event for a prune), which states the + * heuristic token price of the exact replaced range so a pure consumer + * can subtract it without retaining per-node prices. The replacement MUST + * be appended synchronously right after this event. + */ + 'compact/prune': { + /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ + shadowedTokenCount: number + } } } diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 38098c89b7..f063609808 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -105,12 +105,12 @@ describe('CompactService seam', () => { it('exposes the abstract contract methods', async () => { const ctx = new Context() const svc = new StubCompactService(ctx) - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull() const signal = new AbortController().signal expect(await svc.compactNow({ ...stubAgent(session), - reserveTurnAdmission: () => () => undefined, + runMaintenance: task => task(new AbortController().signal), }, signal)).toBeNull() expect(svc.lastSignal).toBe(signal) }) @@ -118,7 +118,7 @@ describe('CompactService seam', () => { it('compact/* events merge into SessionEventMap and are log-only', async () => { const ctx = new Context() const svc = new StubCompactService(ctx) - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) const original = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, @@ -149,7 +149,7 @@ describe('CompactService seam', () => { it('threads the cancellation signal through to the backend', async () => { const ctx = new Context() const svc = new StubCompactService(ctx) - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) const controller = new AbortController() const original = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 63316c3c2d..c5a68c9de6 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -23,7 +23,7 @@ const summary = (overrides: Record<string, unknown> = {}) => ({ }) function startTurn(session: ReturnType<Context['sessions']['create']>, turn = 1): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } describe('compaction invariants', () => { @@ -56,7 +56,7 @@ describe('compaction invariants', () => { it('clears an inherited open compaction trace at end-seed during replay', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const source = new Session(SessionId('stale-compaction-source')) + const source = Session.create(SessionId('stale-compaction-source')) source.append('compact/start', { turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { seed: source.events, @@ -76,7 +76,7 @@ describe('compaction invariants', () => { it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const source = new Session(SessionId('stale-numbered-compaction-source')) + const source = Session.create(SessionId('stale-numbered-compaction-source')) startTurn(source) source.append('compact/start', { turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { @@ -97,7 +97,7 @@ describe('compaction invariants', () => { it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const source = new Session(SessionId('stale-repaired-compaction-source')) + const source = Session.create(SessionId('stale-repaired-compaction-source')) source.append('compact/start', { turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) @@ -123,7 +123,7 @@ describe('compaction invariants', () => { it('rejects a closed standalone bracket that contains a turn before end-seed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const source = new Session(SessionId('closed-nested-compaction-source')) + const source = Session.create(SessionId('closed-nested-compaction-source')) source.append('compact/start', { turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) @@ -142,7 +142,7 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('compact/start', { turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) @@ -152,11 +152,11 @@ describe('compaction invariants', () => { it('adopts a bare session and ignores unrelated committed events', async () => { const ctx = await setup() - const session = new Session(SessionId('bare-compaction-session')) + const session = Session.create(SessionId('bare-compaction-session')) expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index 56f861fcf7..73d31bec84 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -25,7 +25,7 @@ function after(session: Session, type: SessionEvent['type'], nth = 0): boolean { } function closedToolStep(): Session { - const session = new Session(SessionId('closed-tool-step')) + const session = Session.create(SessionId('closed-tool-step')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' }, @@ -64,7 +64,7 @@ describe('tool-pairing boundaries', () => { expect(before(closed, 'tool/result')).toBe(false) expect(after(closed, 'tool/result')).toBe(true) - const open = new Session(SessionId('open-tool-step')) + const open = Session.create(SessionId('open-tool-step')) open.append('assistant/message', { turn: 1, step: 1, @@ -81,7 +81,7 @@ describe('tool-pairing boundaries', () => { }) it('requires every result from a multiple-call assistant message', () => { - const session = new Session(SessionId('multiple-calls')) + const session = Session.create(SessionId('multiple-calls')) session.append('assistant/message', { turn: 1, step: 1, @@ -119,7 +119,7 @@ describe('tool-pairing boundaries', () => { }) it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => { - const midStep = new Session(SessionId('neutral-mid-step')) + const midStep = Session.create(SessionId('neutral-mid-step')) midStep.append('assistant/message', { turn: 1, step: 1, @@ -147,7 +147,7 @@ describe('tool-pairing boundaries', () => { expect(before(midStep, 'user/message')).toBe(false) expect(after(midStep, 'user/message')).toBe(false) - const free = new Session(SessionId('neutral-free')) + const free = Session.create(SessionId('neutral-free')) free.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'idle injection' }], source: { kind: 'user' }, @@ -187,7 +187,7 @@ describe('tool-pairing surface identity', () => { }) it('rejects missing seqs before and after, including an empty surface', () => { - const session = new Session(SessionId('missing-membership')) + const session = Session.create(SessionId('missing-membership')) const missing = 999 expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/) expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/) @@ -271,8 +271,7 @@ describe('tool-pairing cache refresh', () => { expect(eventIndexReads).toBe(3) events.push({ - type: 'turn/end', seq: 3, time: 3, - data: { turn: 1, reason: { kind: 'completed' } }, + type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) expect(eventCollectionReads).toBe(1) @@ -367,7 +366,7 @@ describe('tool-pairing cache refresh', () => { describe('tool-pairing corrupt surfaces', () => { it('throws for an orphan result during a rebuild', () => { - const session = new Session(SessionId('orphan-rebuild')) + const session = Session.create(SessionId('orphan-rebuild')) session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ @@ -380,7 +379,7 @@ describe('tool-pairing corrupt surfaces', () => { }) it('retries an orphan result in an appended tail without committing partial cache state', () => { - const session = new Session(SessionId('orphan-tail')) + const session = Session.create(SessionId('orphan-tail')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' }, }), SURFACE) diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 7836a2a03c..f3acd7f2bf 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/README.md -README.md: fce6e21816d261171aaeaa217171580adb7c43f9 -README.zh.md: b8a4d68ca6892b51ed52479a7296513f5edcc292 +README.md: a0b5eef51740b3aa67b9e158a00357f849b8bf57 +README.zh.md: d6ed8050f3a64f740a951883f0dc321ac08e978a diff --git a/packages/context/README.md b/packages/context/README.md index fce6e21816..a0b5eef517 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in. | Package | Role | ctx key | |---|---|---| -| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | -| `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/step`, reads `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferences` | +| [`time-context/`](time-context/README.md) | Current-time and elapsed-time context | — | +| [`tmux-context/`](tmux-context/README.md) | tmux location context | — | +| [`workspace-context/`](workspace-context/README.md) | Workspace-instruction context | — | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index b8a4d68ca6..d6ed8050f3 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -1,14 +1,14 @@ -# context/:请求上下文扩展 +# context/ — 请求上下文扩展 [English](README.md) | 中文 -这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 与 `tmux-context` 均需显式启用,标准 TUI 组合包则会显式组合 `session-reference`。 +在不定义工具的情况下添加面向模型请求上下文的产品插件。`workspace-context` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context` 和 `session-reference` 需主动启用。 | 包 | 职责 | ctx key | |---|---|---| -| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | -| `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) | -| `tmux-context/` | 持久化的逐轮次上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/step`,读取 `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | +| [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferences` | +| [`time-context/`](time-context/README.md) | 当前时间与耗时上下文 | — | +| [`tmux-context/`](tmux-context/README.md) | tmux 位置上下文 | — | +| [`workspace-context/`](workspace-context/README.md) | workspace 指令上下文 | — | -[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 +[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释其逐 agent(智能体)/会话隔离和生命周期拆分。 diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index fff2b196ad..24fb2c24dc 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/session-reference/README.md -README.md: 66df45b18df6d859239c8d3216d6c8b9fa61ab23 -README.zh.md: b59a98b86baf88f53429bc2409ad1776b1638736 +README.md: 145ec112d6b9cce9e9eb1567c60cde41c6d6eb3c +README.zh.md: 71bab6c94161a37de507482405fb8362e08a89fa diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 66df45b18d..145ec112d6 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly. +`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. Hosts that support cross-session mentions may opt into the service. ## Public API @@ -12,9 +12,9 @@ English | [中文](README.zh.md) ## Snapshot semantics -Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. +Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The standard TUI preserves admission ownership without attaching context to the generic inbox record: outside the next-step acceptance window, a one-shot `agent/prompt-submit` wrapper adds the snapshot only to an allowed decision; during prompt admission or an open turn, `inject()` and `steer()` stage beside each other for the same safe boundary. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. When the agent is idle, the standard TUI installs a one-shot `agent/pre-step` wrapper that adds the snapshot only to an `enter` decision containing the claimed direct prompt. While the agent is running, it calls `inject()` immediately before `steer()`, placing both messages in the next-step inbox for the same later claim. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message`. Later source mutation, compaction, or deletion cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index b59a98b86b..71bab6c941 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。 +`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。支持跨会话 mention 的宿主可以主动启用该服务。 ## 公开 API @@ -12,9 +12,9 @@ ## 快照语义 -准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、用户直接发出的 `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含固化前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 +准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含固化前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。标准 TUI 在不把上下文附加到通用 inbox 记录的情况下保留接纳归属:next-step 接收窗口之外,一次性 `agent/prompt-submit` 包装层只为获准决策添加快照;提示词接纳期间或轮次打开时,`inject()` 与 `steer()` 会并排暂存到同一安全边界。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message` 或 `steering/message`。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。agent 空闲时,标准 TUI 会安装一次性的 `agent/pre-step` 包装层,只把快照添加到包含已领取直接提示词的 `enter` 决策。agent 运行时,它会紧接着调用 `inject()` 和 `steer()`,把两条消息放入 next-step inbox,等待后续同一次领取。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`。后续源变更、压缩或删除都无法改变目标回放。 ## 配置 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index df2cd96c9f..48111ca09b 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index d368c2e1e5..01a6965027 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -199,6 +199,7 @@ export class SessionReferenceService extends Service { const prompt = renderPrompt(rendered.map(source => source.data)) const source: SessionReferenceSource = { kind: 'session-reference', + form: 'recall', version: 1, references: rendered.map((source, index) => ({ sessionId: source.data.sessionId, diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index caf7454c08..4c2ba4d811 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -44,12 +44,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 }) break } - case 'steering/message': { - if (event.data.message.source.kind !== 'user') break - const text = textContent(event.data.message.content) - if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 }) - break - } case 'assistant/message': { const text = textContent(event.data.message.content) if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 }) diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 78df17058d..0693e0d212 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -6,6 +6,8 @@ import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' /** Durable provenance for one prepared cross-session context. */ export interface SessionReferenceSource { kind: 'session-reference' + /** Material lifted out of another session's log (`recall` context form). */ + form: 'recall' version: 1 references: { sessionId: string diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index d9acbe89bb..b9e5e55241 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -97,25 +97,19 @@ function appendConversation(session: Session): void { { surfaceOp: 'append' }, ) session.append( - 'steering/message', - { - turn: 2, - message: createUserMessage({ - content: [{ type: 'text', text: 'human steer' }], - source: { kind: 'user' }, - }), - }, + 'user/message', + createUserMessage({ + content: [{ type: 'text', text: 'human steer' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }, ) session.append( - 'steering/message', - { - turn: 2, - message: createUserMessage({ - content: [{ type: 'text', text: 'plugin steer' }], - source: { kind: 'plugin', plugin: 'goal' }, - }), - }, + 'user/message', + createUserMessage({ + content: [{ type: 'text', text: 'plugin steer' }], + source: { kind: 'plugin', plugin: 'goal' }, + }), { surfaceOp: 'append' }, ) session.append( @@ -161,14 +155,11 @@ function appendConversation(session: Session): void { { surfaceOp: 'append' }, ) session.append( - 'steering/message', - { - turn: 2, - message: createUserMessage({ - content: [{ type: 'reasoning', text: 'empty projected steering' }], - source: { kind: 'user' }, - }), - }, + 'user/message', + createUserMessage({ + content: [{ type: 'reasoning', text: 'empty projected steering' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }, ) session.append( @@ -639,7 +630,7 @@ describe('session reference discovery and preparation', () => { expect(JSON.stringify(before)).toContain('durable referenced fact') expect(JSON.stringify(before)).toContain('use @source') expect(JSON.stringify(before)).not.toContain('later source mutation') - expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) + expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) }) it('rejects direct invalid configuration before service publication', async () => { diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index d54c4ddde0..3ddda9bc7b 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/time-context/README.md -README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10 -README.zh.md: 1133715ebb3348d6e3dbbf8bbef169d6d8c56d8f +README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8 +README.zh.md: fd338cc192c2168915017dd22ac6a1631d8cc21b diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 9fe8188554..9956918c63 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -16,21 +16,21 @@ Opt-in durable context with the current zoned time and elapsed time sampled duri When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. -`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. ## Timing semantics -The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it adds one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` and before ordinary automatic compaction with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed, rejected, or failed pre-step records nothing. Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. -A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback. +A time reading records an entered pre-step batch, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, but a downstream pre-step listener that rejects or fails prevents it from being recorded. The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading. -The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one. +The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one. ## Model Experience diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index 1133715ebb..fd338cc192 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。 +可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。 ## 配置 @@ -16,21 +16,21 @@ 省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。 -`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,对每次信号尚未中止的合格步骤前尝试执行追加。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时执行追加。 +`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且返回 enter 的合格 pre-step 添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。 ## 时序语义 -该插件会前置一个 `agent/step` 监听器。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩(compaction)之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。 +该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、reject 或失败的 pre-step 不会记录任何内容。 正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。 第 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。 -时间读数记录的是一次请求准备尝试,不是已提交步骤或已传输请求。因为 listener 首先运行,后续的步骤前监听器取消或使该尝试失败时,该追加可能仍会保留。日志仅追加,该插件不执行回滚。 +时间读数记录的是一个已进入步骤的 pre-step 批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游 pre-step 监听器 reject 或失败时,该读数不会被记录。 单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。 -时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 处使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:失败的准备可能留下额外时间读数,而间隔抑制可让请求复用现有历史,无需添加时间读数。 +时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。 ## 模型体验 @@ -60,7 +60,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>. #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 2b3ad9fca6..8e8421fd01 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 01d1393f3c..4cc9e5de9c 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -1,26 +1,26 @@ /** - * Opt-in request-preparation clock context. Eligible pre-step attempts append - * durable, source-attributed time readings to conversation history. + * Opt-in request clock context. Eligible steps add durable, + * source-attributed time readings to the request history. * * @module @deepseek-ai/dsh-time-context */ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -/** The agent registry that owns the pre-step lifecycle seam. */ +/** The agent registry that owns pre-step processing. */ export const inject = ['agents'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */ + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number } @@ -65,7 +65,6 @@ function precedingMessageTime(agent: Agent): number | undefined { case 'user/message': case 'assistant/message': case 'tool/result': - case 'steering/message': return event.time default: // Merge-extensible session events: non-surface records are not messages. @@ -157,23 +156,34 @@ export function apply(ctx: Context, config: Config): void { } const resolvedTimeZone = formatter.resolvedOptions().timeZone - ctx.on('agent/step', ( + ctx.on('agent/pre-step', async ( agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ) => { - if (signal.aborted) return + _messages, + { turn, step, signal }, + next, + ): Promise<PreStepDecision> => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { const lastInjection = latestInjectionTime(agent) if (lastInjection !== undefined && now >= lastInjection - && now - lastInjection < refreshIntervalMs) return + && now - lastInjection < refreshIntervalMs) return decision } const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn) - agent.inject(createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } })) + const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone) + return { + kind: 'enter', + messages: [ + ...decision.messages, + createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, + }), + ], + } }, { prepend: true }) } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index aa8f0418dd..ec1fa015ed 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,31 +18,27 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Derive the pre-step position at which a time-context reading may append. */ +/** Derive the entered step boundary at which a time-context reading may append. */ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { - const currentTurnEvents: SessionEvent[] = [] - let openTurn: number | undefined for (const event of history.slice().reverse()) { - if (event.type === 'turn/end') { - fail('time-context reading must be appended inside an open turn') - } - if (event.type === 'turn/start') { - openTurn = event.data.turn - break - } - currentTurnEvents.push(event) - } - if (openTurn === undefined) fail('time-context reading must be appended inside an open turn') - - for (const event of currentTurnEvents) { - if (event.type === 'step/start') { - fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`) - } - if (event.type === 'step/end') { - return { turn: openTurn, step: event.data.step + 1 } + switch (event.type) { + case 'step/start': + return { turn: event.data.turn, step: event.data.step } + case 'turn/start': + case 'step/end': + case 'turn/end': + case 'request/header': + case 'assistant/chunk': + case 'assistant/message': + case 'tool/call': + case 'tool/result': + fail('time-context reading must be appended at a prompt boundary') + break + default: + break } } - return { turn: openTurn, step: 1 } + fail('time-context reading must be appended at a prompt boundary') } /** Validate one plugin-attributed time reading against its session position and timestamp. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 59daf23904..59ffa66a89 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -44,12 +44,12 @@ function reading( } function preparing(turn: number, step: number): Session { - const session = new Session(SessionId(`time-invariant-${turn}-${step}`)) + const session = Session.create(SessionId(`time-invariant-${turn}-${step}`)) for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { - session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: priorTurn }) session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } }) } - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -58,6 +58,7 @@ function preparing(turn: number, step: number): Session { session.append('step/start', { turn, step: priorStep }) session.append('step/end', { turn, step: priorStep }) } + session.append('step/start', { turn, step }) return session } @@ -87,13 +88,13 @@ describe('time-context invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-valid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) appendReading(session, reading()) - session.append('step/start', { turn: 1, step: 1 }) await ctx.plugin(InvariantService, { enabled: true }) await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined() @@ -103,7 +104,8 @@ describe('time-context invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-invalid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, @@ -125,19 +127,22 @@ describe('time-context invariants', () => { it('rejects a reading after cancellation closes the turn', async () => { const ctx = await setup() const session = preparing(1, 2) - session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) - .toThrow(/inside an open turn/) + .toThrow(/at a prompt boundary/) }) - it('rejects a reading after step/start or without any open turn', async () => { + it('rejects a reading outside a prompt boundary', async () => { const ctx = await setup() - const started = preparing(1, 1) - started.append('step/start', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/) + const ended = preparing(1, 1) + ended.append('step/end', { turn: 1, step: 1 }) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) + const notEntered = Session.create(SessionId('time-invariant-turn-only')) + notEntered.append('turn/start', { turn: 1 }) + expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/) expect(() => { - ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) - }).toThrow(/inside an open turn/) + ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading())) + }).toThrow(/at a prompt boundary/) }) it.each([ @@ -180,7 +185,7 @@ describe('time-context invariants', () => { expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow() expect(() => { ctx.emit('session/event', preparing(1, 1), { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) ctx.emit('tools/change') }).not.toThrow() diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 02704d3eba..33d63cf843 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -54,9 +54,16 @@ describe('time-context through a real headless cordis.yml', () => { expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) for (let index = 0; index < contexts.length; index += 1) { - expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) expect(contexts[index]!.surfaceOp).toBe('append') - expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + // `snapshot` form: one named contribution whose text is exactly what the + // model read, so a consumer attributes it without re-splitting prose. + expect(contexts[index]!.data.source).toMatchObject({ + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context' }], + }) } const contextText = contexts.map(event => event.data.content .filter(block => block.type === 'text') @@ -65,10 +72,11 @@ describe('time-context through a real headless cordis.yml', () => { expect(contextText[0]).toMatch( /Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, ) - expect(contextText[0]).toMatch( + expect(contextText[0]).toContain('Elapsed since the preceding model-visible message: unavailable.') + expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/) + expect(contextText[1]).toMatch( /Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, ) - expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/) const headers = events.filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2d126ff5f9..94228bbc9f 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -17,7 +17,7 @@ const SIGNAL = new AbortController().signal beforeEach(() => { process.env['TZ'] = 'UTC' - vi.useFakeTimers() + vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(BASE) }) @@ -40,24 +40,21 @@ function sessionAgent(session: Session, id = 'agent'): Agent { id: SessionId(id), options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'running', - acceptsNextStep: true, ctx: new Context(), - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('time-context must append directly to the open step') }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } function openMessageTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -83,7 +80,17 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise<void> { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } } function textResponse(text: string): StreamChunk[] { @@ -141,7 +148,7 @@ function requestText(request: GenerateOptions): string { describe('durable step context', () => { it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => { const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) - const session = new Session(SessionId('first')) + const session = Session.create(SessionId('first')) openMessageTurn(session, 1) vi.setSystemTime(BASE + 90_061_000) @@ -154,14 +161,26 @@ describe('durable step context', () => { const event = session.events.at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + // The reading is a `snapshot`-form context: one named contribution whose + // text is exactly what the model read, so a consumer attributes it without + // re-splitting prose. + expect(event.data.source).toEqual({ + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ + name: 'time-context', + text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', + }], + }) expect(event.surfaceOp).toBe('append') }) it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => { const { ctx } = await mount() - const session = new Session(SessionId('unavailable')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('unavailable')) + session.append('turn/start', { turn: 1 }) await fire(ctx, sessionAgent(session), 1, 1) @@ -175,7 +194,7 @@ describe('durable step context', () => { ['zero interval', { refreshIntervalMs: 0 }], ] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => { const { ctx } = await mount(config) - const session = new Session(SessionId('later-step')) + const session = Session.create(SessionId('later-step')) const agent = sessionAgent(session) openMessageTurn(session, 3) await fire(ctx, agent, 3, 1) @@ -191,7 +210,7 @@ describe('durable step context', () => { it('reports an unavailable later-step baseline at the matching turn boundary', async () => { const { ctx } = await mount() - const session = new Session(SessionId('later-step-boundary')) + const session = Session.create(SessionId('later-step-boundary')) openMessageTurn(session, 4) await fire(ctx, sessionAgent(session), 4, 2) @@ -203,7 +222,7 @@ describe('durable step context', () => { it('reports an unavailable later-step baseline when event lookup is exhausted', async () => { const { ctx } = await mount() - const session = new Session(SessionId('later-step-exhausted')) + const session = Session.create(SessionId('later-step-exhausted')) await fire(ctx, sessionAgent(session), 1, 2) @@ -214,7 +233,7 @@ describe('durable step context', () => { it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => { const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const session = new Session(SessionId('backward')) + const session = Session.create(SessionId('backward')) const agent = sessionAgent(session) openMessageTurn(session, 1) await fire(ctx, agent, 1, 1) @@ -228,7 +247,7 @@ describe('durable step context', () => { it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => { const { ctx } = await mount({ refreshIntervalMs: 1_000 }) - const original = new Session(SessionId('seed-source')) + const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') @@ -244,7 +263,7 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = new Session(SessionId('resumed'), [...original.events]) + const resumed = Session.create(SessionId('resumed'), [...original.events]) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) @@ -266,7 +285,7 @@ describe('durable step context', () => { it('applies a positive interval across turns without sharing state between sessions', async () => { const { ctx } = await mount({ refreshIntervalMs: 1_000 }) - const first = new Session(SessionId('interval-first')) + const first = Session.create(SessionId('interval-first')) const firstAgent = sessionAgent(first, 'first-agent') openMessageTurn(first, 1) await fire(ctx, firstAgent, 1, 1) @@ -277,7 +296,7 @@ describe('durable step context', () => { const beforeSkip = first.events.length await fire(ctx, firstAgent, 2, 1) - const independent = new Session(SessionId('interval-independent')) + const independent = Session.create(SessionId('interval-independent')) openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) @@ -286,22 +305,17 @@ describe('durable step context', () => { expect(contextTexts(independent)).toHaveLength(1) }) - it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => { + it('skips an already-aborted prompt submission', async () => { const { ctx } = await mount() - const session = new Session(SessionId('ordering')) + const session = Session.create(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') - }) await fire(ctx, agent, 1, 1) const abort = new AbortController() abort.abort() await fire(ctx, agent, 1, 2, abort.signal) - expect(ordinarySawContext).toBe(true) expect(contextTexts(session)).toHaveLength(1) }) }) @@ -311,7 +325,7 @@ describe('configuration and lifecycle', () => { process.env['TZ'] = 'Asia/Shanghai' const { ctx } = await mount() process.env['TZ'] = 'America/New_York' - const session = new Session(SessionId('system-zone')) + const session = Session.create(SessionId('system-zone')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -345,7 +359,7 @@ describe('configuration and lifecycle', () => { it('removes its listener when the plugin fiber disposes', async () => { const { ctx, fiber } = await mount() - const session = new Session(SessionId('dispose')) + const session = Session.create(SessionId('dispose')) const agent = sessionAgent(session) openMessageTurn(session, 1) await fire(ctx, agent, 1, 1) @@ -359,28 +373,24 @@ describe('configuration and lifecycle', () => { describe('real agent-loop request history', () => { it.each([ - ['throws', 'error'], - ['cancels', 'aborted'], - ] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => { + ['throws'], + ['cancels'], + ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) - let laterSawReading = false - ctx.on('agent/step', (subject) => { - laterSawReading = contextTexts(subject.session).length === 1 + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) + return next() }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(laterSawReading).toBe(false) expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind) await ctx.fiber.dispose() }) @@ -408,7 +418,7 @@ describe('real agent-loop request history', () => { expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { - expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) } expect(contexts.every(event => event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context' @@ -417,7 +427,7 @@ describe('real agent-loop request history', () => { const firstRequestText = requestText(adapter.requests[0]!) const secondRequestText = requestText(adapter.requests[1]!) expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:') - expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.') + expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.') expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:') expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:') expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:') @@ -445,7 +455,7 @@ describe('real Loader export path', () => { await ctx.plugin(AgentRegistry) const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0] await ctx.plugin(plugin) - const session = new Session(SessionId('loader')) + const session = Session.create(SessionId('loader')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:') diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index 213e74f2ca..c0446d8dfe 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md -README.md: 053206797398aa952522298e82992a7320daf74c -README.zh.md: 439f3e7712b0803b07a9a7e9dd10d9e863876154 +README.md: 26eff205b399bfcd20f55e91ef46cadbd70ae604 +README.zh.md: 6950443ce7ce9c46a605ef70500dd4f63c44163b diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index 0532067973..26eff205b3 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. The shipped TUI mounts it; `dsh-agent-spine-demo` and the Web/headless surfaces do not. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). +Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. It is sampled once per turn during model-request preparation and is not part of the shipped Web/headless composition. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). ## Config @@ -17,7 +17,7 @@ Opt-in durable context naming the tmux session, window, and pane this agent proc ## How it reads tmux -The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: +The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: ```sh [ -n "$TMUX_PANE" ] || exit 1 @@ -33,7 +33,7 @@ State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane ## Timing semantics -When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). +The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it prepends one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded. ## Model Experience diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 439f3e7712..6950443ce7 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。已交付的 TUI 会挂载它;`dsh-agent-spine-demo` 与 Web/无头界面均不挂载。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 +可选启用的持久上下文,记录本 agent(智能体)进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次;随附 Web/无头组合不包含它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 ## 配置 @@ -17,7 +17,7 @@ ## 如何读取 tmux -插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: +插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: ```sh [ -n "$TMUX_PANE" ] || exit 1 @@ -33,7 +33,7 @@ exec tmux display-message -t "$TMUX_PANE" -p '<format>' ## 时序语义 -当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 +该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次前添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游 pre-step 监听器 reject 或失败时,该读数不会被记录。 ## 模型体验 @@ -55,9 +55,9 @@ window active=<0|1>, pane active=<0|1>, layout <window-layout> 每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 -#### KV 缓存影响 +#### KV Cache 影响 -只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 +只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV Cache 条目失效。 ## 已知限制与后续工作 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 222cdfa55b..524036a667 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 35f4c4a22f..ff9243416e 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -3,7 +3,7 @@ * append durable, source-attributed context naming the tmux session, window, * and pane this agent process runs in, plus the window's pane-tree layout. * - * The plugin pulls state once per turn, on the first step (`step === 1`), by + * The plugin pulls state once per turn, for the first request (`step === 1`), by * running one `tmux display-message` through the `ctx.bash` executor seam. It * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by * matching the pane's `#{pane_tty}` against this process's controlling terminal, @@ -20,14 +20,14 @@ import type { Context, LoggerService } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' -/** The agent registry that owns the `agent/step` lifecycle seam. */ +/** The agent registry that owns pre-step processing. */ export const inject = ['agents'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -206,7 +206,7 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { } /** - * Register a prepended `agent/step` listener for the lifetime of `ctx`. + * Register a prepended pre-step listener for the lifetime of `ctx`. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. * @throws when the refresh interval is invalid. @@ -215,27 +215,35 @@ export function apply(ctx: Context, config: Config): void { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - ctx.on('agent/step', async ( + ctx.on('agent/pre-step', async ( agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ): Promise<void> => { - if (signal.aborted || step !== 1) return + _messages, + { turn, step, signal }, + next, + ): Promise<PreStepDecision> => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('bash') - if (bash === undefined) return + if (bash === undefined) return decision const previous = latestInjectedState(agent) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { const now = Date.now() - if (now >= previous.time && now - previous.time < refreshIntervalMs) return + if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision } const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) - if (location === undefined) return + if (location === undefined) return decision const state = renderState(location) - if (previous !== undefined && previous.state === state) return - agent.inject(createUserMessage({ - content: [{ type: 'text', text: renderReading(location, turn) }], - source: { kind: 'plugin', plugin: name }, - })) + if (previous !== undefined && previous.state === state) return decision + const text = renderReading(location, turn) + return { + kind: 'enter', + messages: [ + createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, + }), + ...decision.messages, + ], + } }, { prepend: true }) } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 1d94399184..d7e4a0a7fb 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' @@ -96,24 +96,21 @@ function sessionAgent(session: Session, id = 'agent'): Agent { id: SessionId(id), options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'running', - acceptsNextStep: true, ctx: new Context(), - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - updateInbox: () => 'not-found', - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, send: () => {}, - reserveTurnAdmission: () => undefined, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('tmux-context must append directly to the open step') }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } function openMessageTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -139,7 +136,17 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise<void> { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } } afterEach(() => { @@ -150,7 +157,7 @@ afterEach(() => { describe('tmux-context injection', () => { it('injects the tmux location on the first step of a turn', async () => { const { ctx } = await mount({}, true) - const session = new Session(SessionId('first')) + const session = Session.create(SessionId('first')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -163,13 +170,20 @@ describe('tmux-context injection', () => { ]) const event = session.events.at(-1) if (event?.type !== 'user/message') throw new Error('missing tmux context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) + // `snapshot` form: one named contribution carrying exactly the reading the + // model saw, so a consumer attributes it without re-splitting prose. + expect(event.data.source).toMatchObject({ + kind: 'plugin', + plugin: 'tmux-context', + form: 'snapshot', + sections: [{ name: 'tmux-context' }], + }) expect(event.surfaceOp).toBe('append') }) it('queries the pane this process runs in and matches its controlling tty', async () => { const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('command')) + const session = Session.create(SessionId('command')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -187,7 +201,7 @@ describe('tmux-context injection', () => { it('does not run on later steps of a turn', async () => { const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('later-step')) + const session = Session.create(SessionId('later-step')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 2) @@ -198,7 +212,7 @@ describe('tmux-context injection', () => { it('re-injects a new turn only when tmux state changed', async () => { const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('change')) + const session = Session.create(SessionId('change')) const agent = sessionAgent(session) openMessageTurn(session, 1) @@ -226,7 +240,7 @@ describe('tmux-context injection', () => { vi.useFakeTimers() vi.setSystemTime(1_000) const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) - const session = new Session(SessionId('interval')) + const session = Session.create(SessionId('interval')) const agent = sessionAgent(session) openMessageTurn(session, 1) @@ -253,7 +267,7 @@ describe('tmux-context injection', () => { describe('tmux-context prior-reading resilience', () => { it('treats a prior non-text plugin reading as absent and injects afresh', async () => { const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-non-text')) + const session = Session.create(SessionId('prior-non-text')) const agent = sessionAgent(session) openMessageTurn(session, 1) session.append('user/message', createUserMessage({ @@ -269,7 +283,7 @@ describe('tmux-context prior-reading resilience', () => { it('treats a prior single-line plugin reading (no newline) as empty state', async () => { const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-single-line')) + const session = Session.create(SessionId('prior-single-line')) const agent = sessionAgent(session) openMessageTurn(session, 1) session.append('user/message', createUserMessage({ @@ -288,7 +302,7 @@ describe('tmux-context prior-reading resilience', () => { describe('tmux-context no-op paths', () => { it('is a no-op when no bash executor is mounted', async () => { const { ctx } = await mount() - const session = new Session(SessionId('no-bash')) + const session = Session.create(SessionId('no-bash')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -299,7 +313,7 @@ describe('tmux-context no-op paths', () => { it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { const { ctx, bash } = await mount({}, true) bash.result = runResult('', { exitCode: 1 }) - const session = new Session(SessionId('outside-tmux')) + const session = Session.create(SessionId('outside-tmux')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -310,7 +324,7 @@ describe('tmux-context no-op paths', () => { it('is a no-op when the reading has the wrong field count', async () => { const { ctx, bash } = await mount({}, true) bash.result = runResult('0\\t1\\tnode\n') - const session = new Session(SessionId('malformed')) + const session = Session.create(SessionId('malformed')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -321,7 +335,7 @@ describe('tmux-context no-op paths', () => { it('is a no-op when the pane id is empty', async () => { const { ctx, bash } = await mount({}, true) bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) - const session = new Session(SessionId('empty-pane')) + const session = Session.create(SessionId('empty-pane')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -333,7 +347,7 @@ describe('tmux-context no-op paths', () => { const { ctx, bash } = await mount({}, true) bash.runError = new Error('bash executor unavailable') const warn = vi.spyOn(ctx.logger, 'warn') - const session = new Session(SessionId('run-rejected')) + const session = Session.create(SessionId('run-rejected')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -346,7 +360,7 @@ describe('tmux-context no-op paths', () => { const { ctx, bash } = await mount({}, true) bash.resolveError = new Error('command denied by policy') const warn = vi.spyOn(ctx.logger, 'warn') - const session = new Session(SessionId('resolve-rejected')) + const session = Session.create(SessionId('resolve-rejected')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -360,7 +374,7 @@ describe('tmux-context no-op paths', () => { // Non-Error throw: the executor seam is typed, but a bad impl can reject with anything. bash.runError = 'spawn refused' as unknown as Error const warn = vi.spyOn(ctx.logger, 'warn') - const session = new Session(SessionId('non-error-rejection')) + const session = Session.create(SessionId('non-error-rejection')) openMessageTurn(session, 1) await fire(ctx, sessionAgent(session), 1, 1) @@ -369,19 +383,11 @@ describe('tmux-context no-op paths', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawn refused')) }) - it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { + it('skips an already-aborted prompt submission', async () => { const { ctx } = await mount({}, true) - const session = new Session(SessionId('ordering')) + const session = Session.create(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some( - event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context', - ) - }) const abort = new AbortController() abort.abort() @@ -389,7 +395,6 @@ describe('tmux-context no-op paths', () => { expect(contextTexts(session)).toHaveLength(0) await fire(ctx, agent, 1, 1) - expect(ordinarySawContext).toBe(true) expect(contextTexts(session)).toHaveLength(1) }) }) diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..bd8c55d78c 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md -README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d -README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca +README.md: 7add269c7a1b38e9624cf6d0368151661bc72a6b +README.zh.md: 3456c5c36275e8e521b312b1b0b7e1dd102ca23c diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..7add269c7a 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. +The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -48,11 +48,11 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us ## State And Refresh -Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it queues a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes at its first pre-step; an entering first request records that context in the same step. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. ## Configuration diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..3456c5c362 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。 +每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when ## 状态与刷新 -模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会排队当前基线。恢复的 loop 始终在第一次 pre-step 重新组合当前基线,并对账仍可见的动态 scope;首次请求若进入步骤,就会在同一步骤记录该上下文。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 ## 配置 diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 0c50b8cc17..6548fb49a8 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 52dc76070c..efa9bdf771 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -2,33 +2,28 @@ * Workspace instruction loader for AGENTS.md-compatible files. * * Baseline instructions enter durable context before the first request; successful fs - * tool touches reconcile nested, changed, and removed instructions through - * `tools/post-execute` for the next model request. Plugin lifecycle reads use - * the optional `ctx.fs` provider, so providerless products mount it as a no-op. + * tool touches project nested, changed, and removed instructions into the inbox. + * Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products + * mount it as a no-op. * * @module @deepseek-ai/dsh-workspace-context */ import type { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { isDeepStrictEqual } from 'node:util' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, - commitPendingInstructionContexts, - dynamicInstructionContext, name, - observeInstructionSessionEvent, reconcileInstructionContext, - retainedInstructionVersionUpdates, - rollbackPendingInstructionChanges, workspaceContextMessage, type InstructionVersionCache, - type InstructionVersionUpdate, - type PendingInstructionChange, } from './state.ts' import type { WorkspaceInstructionChange } from './render.ts' @@ -53,153 +48,204 @@ function hasVisibleBaseline(agent: Agent): boolean { }) } +function isWorkspaceContext(message: UserMessage): boolean { + return message.source.kind === 'workspace-instructions' +} + +function sameContextPayload(left: UserMessage, right: UserMessage): boolean { + return isDeepStrictEqual(left.content, right.content) + && isDeepStrictEqual(left.source, right.source) +} + +const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) + +function filePathFromExecution(exec: ToolExecution): string | undefined { + if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined + if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined + if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined + const filePath = exec.arguments.file_path.trim() + return filePath.length > 0 ? filePath : undefined +} + export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) - const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>() - const baselineSessions = new WeakSet<object>() const instructionVersions: InstructionVersionCache = new WeakMap() - const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>() - const baselineLoaded = new WeakSet<object>() - // Sessions whose lifecycle start this mount witnessed. A startup or resume - // emits agent/session-start before the first step; a hot remount attaches to - // an already-live session and never sees it. Resumes always re-compose the - // baseline from current files. Hot remounts retain a baseline only while its - // typed event remains model-visible. - const lifecycleWitnessed = new WeakSet<object>() - const pendingByParent = new Map<ToolExecutionToken, { - agent: Agent - changes: WorkspaceInstructionChange[] - versionUpdates: InstructionVersionUpdate[] - }>() + const projectionLifecycle = new AbortController() + ctx.effect( + () => () => { + projectionLifecycle.abort(new Error('workspace-context disposed')) + }, + 'workspace-context.projectionLifecycle', + ) + // Emit listeners are not awaited, so each projection must compose against the + // inbox produced by earlier file results for the same agent. + const projectionTails = new WeakMap<Agent, Promise<void>>() - ctx.on('agent/session-start', (agent: Agent) => { - lifecycleWitnessed.add(agent.session) - }) - - ctx.on('session/event', (session, event) => { - observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) - }) - - ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => { - if (baselineLoaded.has(agent.session)) return + const compose = async ( + agent: Agent, + signal: AbortSignal, + claimed: readonly UserMessage[], + pending: readonly UserMessage[], + touchedPaths: readonly string[] = [], + ): Promise<UserMessage | undefined> => { + signal.throwIfAborted() if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { - baselineLoaded.add(agent.session) - return + return undefined } const fileSystem = ctx.get('fs') - if (fileSystem === undefined) { - baselineLoaded.add(agent.session) - return + if (fileSystem === undefined) return undefined + if (touchedPaths.length === 0 && pending.length > 0) return pending[0] + const content: UserMessage['content'][number][] = [] + const changes: WorkspaceInstructionChange[] = [] + let desiredBaseline = false + const authorityMessages = [...claimed] + const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message => + message.source.kind === 'workspace-instructions' && message.source.baseline === true) + if (!baselinePresent) { + /* v8 ignore next -- normal agents carry an absolute session cwd. */ + const cwd = agent.session.header.cwd ?? process.cwd() + const instructions = await loadBaselineInstructionSet({ + cwd, + dshHome: resolved.dshHome, + projectRootMarkers: resolved.projectRootMarkers, + maxBytes: resolved.maxBytes, + maxSourceBytes: resolved.maxSourceBytes, + instructionFileCandidates: resolved.instructionFileCandidates, + localInstructionFileCandidates: resolved.localInstructionFileCandidates, + signal, + }, fileSystem) + const baseline = baselineInstructionState(instructions?.included ?? []) + let versionStates = instructionVersions.get(agent.session) + if (versionStates === undefined && baseline.versions.size > 0) { + versionStates = new Map() + instructionVersions.set(agent.session, versionStates) + } + for (const [scope, state] of baseline.versions) versionStates?.set(scope, state) + if (instructions !== undefined && instructions.rendered.text.length > 0) { + content.push(...workspaceContextMessage(instructions.rendered.text).content) + changes.push(...baseline.changes.values()) + desiredBaseline = true + } } - /* v8 ignore next -- normal agents carry an absolute session cwd. */ - const cwd = agent.session.header.cwd ?? process.cwd() - const instructions = await loadBaselineInstructionSet({ - cwd, - dshHome: resolved.dshHome, - projectRootMarkers: resolved.projectRootMarkers, - maxBytes: resolved.maxBytes, - maxSourceBytes: resolved.maxSourceBytes, - instructionFileCandidates: resolved.instructionFileCandidates, - localInstructionFileCandidates: resolved.localInstructionFileCandidates, - signal, - }, fileSystem) - const baseline = baselineInstructionState(instructions?.included ?? []) - baselineSessions.add(agent.session) - instructionVersions.set(agent.session, baseline.versions) - const update = await reconcileInstructionContext( agent, resolved, - pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: false, signal }, + { authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal }, ) if (update !== undefined) { - agent.inject(update.context) + content.push(...update.context.content) + /* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */ + if (update.context.source.kind === 'workspace-instructions') { + changes.push(...update.context.source.changes) + } applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } - const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent) - if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { - const baselineMessage = workspaceContextMessage(instructions.rendered.text) - agent.inject(createUserMessage({ - content: baselineMessage.content, - source: { - kind: 'workspace-instructions', - baseline: true, - changes: [...baseline.changes.values()], - }, - })) - } - baselineLoaded.add(agent.session) - }) + if (content.length === 0) return undefined + return createUserMessage({ + content, + source: { + kind: 'workspace-instructions', + form: 'instructions', + ...desiredBaseline ? { baseline: true } : {}, + changes, + }, + }) + } - ctx.on('tools/post-execute', async ( - exec: ToolExecution, - result: ToolExecutionResult, - next, - ): Promise<PostToolDecision> => { - const downstream = await next() - // A downstream listener/policy blocked this call: the registry turns it - // into a final `isError` result, so treat it like a failed fs touch and - // load nothing. Reconciling here would surface workspace instructions from - // a call the pipeline rejected, violating the "successful fs tool touches" - // contract, and would advance the nested/baseline tracking state off a - // touch that never really happened. - if (downstream.kind === 'block') return downstream - const fileSystem = ctx.get('fs') - if (fileSystem === undefined) return downstream - const update = await dynamicInstructionContext( - exec.agent, - exec, - result, - resolved, - pendingNestedChanges, - baselineSessions, - instructionVersions, - fileSystem, + const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => { + const pending = agent.inbox.nextStep.filter(isWorkspaceContext) + const alreadySupplied = desired !== undefined && ( + claimed.some(message => sameContextPayload(message, desired)) + || agent.session.surface.nodes.some((seq) => { + const event = agent.session.events[seq] + return event?.type === 'user/message' && sameContextPayload(event.data, desired) + }) ) - if (update === undefined) return downstream - pendingVersionUpdates.set(exec.token, update.versionUpdates) - return { - ...downstream, - additionalContexts: [update.context, ...downstream.additionalContexts ?? []], + if (desired === undefined || alreadySupplied) { + for (const message of pending) agent.inbox.remove(message.id) + return } - }) - - ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { - const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? [] - pendingVersionUpdates.delete(exec.token) - if (exec.parent !== undefined) { - if (exec.agent === undefined) return - // Child contexts participate in duplicate suppression within one composite - // run, but remain provisional until the parent reaches its final policy. - const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges) - if (changes.length === 0) return - const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes) - const staged = pendingByParent.get(exec.parent) - if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates }) - else { - staged.changes.push(...changes) - staged.versionUpdates.push(...versionUpdates) + const reusable = pending.find(message => sameContextPayload(message, desired)) + if (reusable !== undefined) { + for (const message of pending) { + if (message !== reusable) agent.inbox.remove(message.id) } return } + const replaced = pending[0] + if (replaced === undefined) agent.inbox.prepend('next-step', desired) + else agent.inbox.replace(replaced.id, desired) + for (const message of pending.slice(1)) agent.inbox.remove(message.id) + } - // The parent result is authoritative: remove every provisional child change, - // then commit only contexts that survived outer post-execute policy. - const staged = pendingByParent.get(exec.token) - if (staged !== undefined) { - pendingByParent.delete(exec.token) - rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges) + const composeAndSync = async ( + agent: Agent, + signal: AbortSignal, + claimed: readonly UserMessage[], + touchedPaths: readonly string[] = [], + ): Promise<void> => { + const pending = agent.inbox.nextStep.filter(isWorkspaceContext) + const desired = await compose(agent, signal, claimed, pending, touchedPaths) + signal.throwIfAborted() + syncInbox(agent, claimed, desired) + } + + const queueProjection = ( + agent: Agent, + touchedPath: string, + ): void => { + const previous = projectionTails.get(agent) ?? Promise.resolve() + const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath])) + .catch((error: unknown) => { + if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error) + }) + projectionTails.set(agent, current) + void current.then(() => { + if (projectionTails.get(agent) === current) projectionTails.delete(agent) + }) + } + + const waitForProjections = async (agent: Agent): Promise<void> => { + let projection: Promise<void> | undefined + while ((projection = projectionTails.get(agent)) !== undefined) await projection + } + + ctx.on('agent/pre-step', async ( + agent: Agent, + messages, + { step, signal }, + next, + ): Promise<PreStepDecision> => { + const decision = await next() + await waitForProjections(agent) + const pending = agent.inbox.nextStep.filter(isWorkspaceContext) + const desired = await compose(agent, signal, messages, pending) + signal.throwIfAborted() + // An empty first entry owns a no-step turn; keep context pending instead + // of turning it into a standalone request. Later entries may be tool continuations. + if (decision.kind === 'reject' || (step === 1 && decision.messages.length === 0)) { + syncInbox(agent, messages, desired) + return decision } - if (exec.agent === undefined) return - const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges) - const stagedVersionUpdates = staged?.versionUpdates ?? [] - const versionUpdates = retainedInstructionVersionUpdates( - [...stagedVersionUpdates, ...ownVersionUpdates], - committed, - ) - applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions) + // A proceeding step settles the pending context: it either enters below as + // `desired`, or its payload is already covered by the batch, so nothing stays pending. + for (const message of pending) agent.inbox.remove(message.id) + if (desired === undefined || decision.messages.some(message => sameContextPayload(message, desired))) { + return decision + } + // Fold the context right after the claimed batch, so the direct prompt + // precedes it and the driver-appended runtime context follows it. + const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message)) + const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired) + return { kind: 'enter', messages: entered } + }) + + ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { + if (result.isError || exec.agent === undefined || exec.signal.aborted) return + const ownPath = filePathFromExecution(exec) + if (ownPath === undefined) return + queueProjection(exec.agent, ownPath) }) } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 7bb24a43b2..d0176eef8f 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -7,9 +7,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' -import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts' import { @@ -34,11 +33,11 @@ import { export const name = 'workspace-context' -const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) - /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' + /** Every workspace context carries instructions read out of a file (the `instructions` context form). */ + form: 'instructions' /** Marks the complete startup/resume baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] @@ -50,13 +49,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Dynamic state waiting for the loop to append its returned context event. */ -export interface PendingInstructionChange { - change: WorkspaceInstructionChange - afterSeq: number - step?: { turn: number; step: number } -} - /** Per-scope metadata cache; instruction prose is deliberately not retained. */ export interface InstructionVersionState { path: string @@ -72,13 +64,13 @@ export interface InstructionVersionState { /** Session-isolated fast-path state keyed by logical instruction scope. */ export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>> -/** A cache transition coupled to the model-visible change that authorizes it. */ +/** A metadata-cache transition associated with one rendered instruction change. */ export interface InstructionVersionUpdate { change: WorkspaceInstructionChange state?: InstructionVersionState } -/** Rendered reconciliation plus cache transitions awaiting final policy. */ +/** Rendered reconciliation plus its metadata-cache transitions. */ export interface ReconciledInstructionContext { context: UserMessage versionUpdates: InstructionVersionUpdate[] @@ -87,7 +79,7 @@ export interface ReconciledInstructionContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'workspace-instructions', changes }, + source: { kind: 'workspace-instructions', form: 'instructions', changes }, }) } @@ -103,14 +95,6 @@ export function workspaceContextMessage(text: string): Message { }) } -function filePathFromExecution(exec: ToolExecution): string | undefined { - if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined - if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined - if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined - const filePath = exec.arguments.file_path.trim() - return filePath.length > 0 ? filePath : undefined -} - function isWorkspaceContextSource( source: unknown, ): source is { kind: 'workspace-instructions'; changes: unknown[] } { @@ -149,7 +133,7 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru function visibleInstructionChanges( agent: Agent, - pending: Map<string, PendingInstructionChange>, + authorityMessages: readonly UserMessage[], ): Map<string, WorkspaceInstructionChange> { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map<string, WorkspaceInstructionChange>() @@ -157,14 +141,15 @@ function visibleInstructionChanges( if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - const waiting = pending.get(change.scope) - if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { - pending.delete(change.scope) - } if (visibleSeqs.has(seq)) visible.set(change.scope, change) } } - for (const { change } of pending.values()) visible.set(change.scope, change) + for (const message of authorityMessages) { + if (!isWorkspaceContextSource(message.source)) continue + for (const change of workspaceInstructionChanges(message.source)) { + visible.set(change.scope, change) + } + } return visible } @@ -210,20 +195,20 @@ function versionStatesFor(session: Session, cache: InstructionVersionCache): Map } /** - * Keep only cache updates whose model-visible changes survived final policy. + * Keep only cache updates represented by rendered changes. * @param updates - proposed updates from one or more reconciliations. - * @param committedChanges - transitions retained on the authoritative result. - * @returns updates authorized by an exact retained transition. + * @param renderedChanges - transitions retained by the renderer. + * @returns updates represented by an exact retained transition. */ export function retainedInstructionVersionUpdates( updates: readonly InstructionVersionUpdate[], - committedChanges: readonly WorkspaceInstructionChange[], + renderedChanges: readonly WorkspaceInstructionChange[], ): InstructionVersionUpdate[] { - return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change))) + return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change))) } /** - * Apply authorized metadata-cache transitions without retaining instruction prose. + * Apply metadata-cache transitions without retaining instruction prose. * @param session - owning session. * @param updates - ordered set/delete transitions. * @param cache - session-isolated metadata cache. @@ -242,164 +227,35 @@ export function applyInstructionVersionUpdates( if (states.size === 0) cache.delete(session) } -function pendingChangesFor( - session: object, - pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, -): Map<string, PendingInstructionChange> { - let pending = pendingBySession.get(session) - if (pending === undefined) { - pending = new Map() - pendingBySession.set(session, pending) - } - return pending -} - -function openStep(session: Session): { turn: number; step: number } | undefined { - const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end') - return boundary?.type === 'step/start' ? boundary.data : undefined -} - -function invalidateInstructionVersions( - session: Session, - scopes: readonly string[], - cache: InstructionVersionCache, -): void { - const states = cache.get(session) - if (states === undefined) return - for (const scope of scopes) states.delete(scope) - if (states.size === 0) cache.delete(session) -} - -/** - * Settle provisional tool-result state against durable session events. - * A matching context event confirms the transition. If its owning step closes - * first, both duplicate suppression and the metadata fast path are re-armed for - * the next successful touch. - * @param session - session whose append-only log emitted `event`. - * @param event - newly committed session event. - * @param pendingBySession - provisional transitions awaiting log confirmation. - * @param versionCache - metadata fast path coupled to those transitions. - */ -export function observeInstructionSessionEvent( - session: Session, - event: SessionEvent, - pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, - versionCache: InstructionVersionCache, -): void { - const pending = pendingBySession.get(session) - if (pending === undefined) return - - switch (event.type) { - case 'user/message': { - if (!isWorkspaceContextSource(event.data.source)) return - for (const change of workspaceInstructionChanges(event.data.source)) { - const waiting = pending.get(change.scope) - if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { - pending.delete(change.scope) - } - } - if (pending.size === 0) pendingBySession.delete(session) - return - } - case 'step/end': { - const discardedScopes: string[] = [] - for (const [scope, waiting] of pending) { - const step = waiting.step - if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue - pending.delete(scope) - discardedScopes.push(scope) - } - if (pending.size === 0) pendingBySession.delete(session) - invalidateInstructionVersions(session, discardedScopes, versionCache) - return - } - default: - // SessionEventMap is merge-extensible; unrelated events do not settle workspace state. - return - } -} - -/** - * Commit only workspace contexts that survived the complete tool pipeline. - * The observe-only `tools/result` notification calls this before the loop can - * append the returned contexts, closing that short pending window without - * trusting an intermediate post-execute decision. - * @param agent - session that will receive the final result contexts. - * @param contexts - immutable contexts on the authoritative top-level result. - * @param pendingBySession - per-session pending transition maps. - * @returns transitions committed into the short pending window. - */ -export function commitPendingInstructionContexts( - agent: Agent, - contexts: readonly UserMessage[] | undefined, - pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, -): WorkspaceInstructionChange[] { - const committed: WorkspaceInstructionChange[] = [] - const step = openStep(agent.session) - for (const context of contexts ?? []) { - if (!isWorkspaceContextSource(context.source)) continue - const changes = workspaceInstructionChanges(context.source) - if (changes.length === 0) continue - const pending = pendingChangesFor(agent.session, pendingBySession) - for (const change of changes) { - pending.set(change.scope, { - change, - afterSeq: agent.session.seq, - ...step === undefined ? {} : { step }, - }) - committed.push(change) - } - } - return committed -} - -/** - * Roll back parent-token state when an enclosing tool result discards deferred - * contexts. A newer transition for the same scope is left intact. - * @param agent - session whose pending state was staged. - * @param changes - exact staged transitions to remove when still current. - * @param pendingBySession - per-session pending transition maps. - */ -export function rollbackPendingInstructionChanges( - agent: Agent, - changes: readonly WorkspaceInstructionChange[], - pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, -): void { - const pending = pendingBySession.get(agent.session) - if (pending === undefined) return - for (const change of changes) { - const current = pending.get(change.scope) - if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope) - } - if (pending.size === 0) pendingBySession.delete(agent.session) -} - function relativeScope(projectRoot: string, dir: string): string { const scope = relativeDisplay(projectRoot, dir) return scope.length === 0 ? '.' : scope } /** - * Compare visible/pending state with provider-visible files and render transitions. + * Compare visible state with provider-visible files and render transitions. * @param agent - session owner whose visible surface supplies durable state. * @param resolved - normalized plugin configuration. - * @param pendingBySession - short pending window before returned context is logged. * @param versionCache - per-session scope metadata used to skip unchanged reads. * @param fileSystem - provider used for current file probes. - * @param options - touched path and whether baseline scopes should participate. + * @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable. */ export async function reconcileInstructionContext( agent: Agent, resolved: ResolvedConfig, - pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, versionCache: InstructionVersionCache, fileSystem: FileSystem, - options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, + options: { + authorityMessages: readonly UserMessage[] + scopeMessages: readonly UserMessage[] + touchedPaths: readonly string[] + includeBaselineScopes: boolean + signal?: AbortSignal + }, ): Promise<ReconciledInstructionContext | undefined> { const session = agent.session - const pending = pendingChangesFor(session, pendingBySession) - const effective = visibleInstructionChanges(agent, pending) + const effective = visibleInstructionChanges(agent, options.authorityMessages) /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = session.header.cwd ?? process.cwd() // TODO(frozen-project-root): retain the baseline root for the loop instance; @@ -419,14 +275,22 @@ export async function reconcileInstructionContext( if (options.includeBaselineScopes) { for (const scope of baselineScopes) scopes.add(scope) } + for (const message of options.scopeMessages) { + /* v8 ignore next -- the plugin passes its workspace-only pending projection. */ + if (!isWorkspaceContextSource(message.source)) continue + for (const change of workspaceInstructionChanges(message.source)) { + if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue + scopes.add(change.scope) + } + } for (const scope of effective.keys()) { if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue const { directory } = decodeScopeKey(scope) if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) else addDirScopes(scopes, directory) } - if (options.touchedPath !== undefined) { - for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir) + for (const touchedPath of options.touchedPaths) { + for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir) } const versions = versionStatesFor(session, versionCache) @@ -452,116 +316,97 @@ export async function reconcileInstructionContext( items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } }) versionUpdates.push({ change }) } + const scopesByDirectory = new Map<string, string[]>() for (const scope of scopes) { const { directory } = decodeScopeKey(scope) - const previous = effective.get(scope) - const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) - if (probe.kind === 'unavailable') { - // Last-good-state: the candidate stays effective, so its cached trimmed - // digest must keep occupying the directory's dedup slot — otherwise an - // identical later sibling would be emitted as a duplicate `set` until the - // next successful reconciliation removed it again. - const cached = versions.get(scope) - if (cached !== undefined && previous !== undefined && previous.action !== 'remove') { - registerKeptTrimmed(directory, cached.trimmedDigest) + const directoryScopes = scopesByDirectory.get(directory) + if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope]) + else directoryScopes.push(scope) + } + for (const [directory, directoryScopes] of scopesByDirectory) { + const itemStart = items.length + const versionUpdateStart = versionUpdates.length + const addedAbsolutePaths: string[] = [] + const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)])) + for (const scope of directoryScopes) { + const previous = effective.get(scope) + const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) + if (probe.kind === 'unavailable') { + if (previous === undefined || previous.action === 'remove') continue + // Same-directory candidates form one deduplicated authority group. If an + // active member cannot be observed, preserve the entire last-good group; + // cache warmth must never decide whether a sibling transition is emitted. + items.splice(itemStart) + versionUpdates.splice(versionUpdateStart) + for (const [candidateScope, prior] of priorVersions) { + if (prior === undefined) versions.delete(candidateScope) + else versions.set(candidateScope, prior) + } + for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath) + keptTrimmedByDir.delete(directory) + break + } + if (probe.kind === 'absent') { + if (previous === undefined || previous.action === 'remove') versions.delete(scope) + else pushRemoval(scope, previous.path) + continue + } + const { file: probedFile } = probe + if (seenAbsolutePaths.has(probedFile.absolutePath)) continue + seenAbsolutePaths.add(probedFile.absolutePath) + addedAbsolutePaths.push(probedFile.absolutePath) + const cached = versions.get(scope) + if ( + cached !== undefined + && cached.path === probedFile.displayPath + && cached.version === probedFile.version + && previous !== undefined + && previous.action !== 'remove' + && previous.path === cached.path + && previous.digest === cached.digest + ) { + // Unchanged and previously rendered: keep it, but an earlier sibling that + // now matches its trimmed content makes this the duplicate to remove. + if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path) + continue } - continue - } - if (probe.kind === 'absent') { - if (previous === undefined || previous.action === 'remove') versions.delete(scope) - else pushRemoval(scope, previous.path) - continue - } - const { file: probedFile } = probe - if (seenAbsolutePaths.has(probedFile.absolutePath)) continue - seenAbsolutePaths.add(probedFile.absolutePath) - const cached = versions.get(scope) - if ( - cached !== undefined - && cached.path === probedFile.displayPath - && cached.version === probedFile.version - && previous !== undefined - && previous.action !== 'remove' - && previous.path === cached.path - && previous.digest === cached.digest - ) { - // Unchanged and previously rendered: keep it, but an earlier sibling that - // now matches its trimmed content makes this the duplicate to remove. - if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path) - continue - } - const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) - if (file === undefined) continue - const currentDigest = instructionContentSha1(file.content) - const trimmedDigest = trimmedInstructionDigest(file.content) - if (registerKeptTrimmed(directory, trimmedDigest)) { - // A distinct file whose trimmed content already appeared earlier in this - // directory: drop it, removing any copy that was previously rendered. - if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path) - else versions.delete(scope) - continue + const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) + if (file === undefined) continue + const currentDigest = instructionContentSha1(file.content) + const trimmedDigest = trimmedInstructionDigest(file.content) + if (registerKeptTrimmed(directory, trimmedDigest)) { + // A distinct file whose trimmed content already appeared earlier in this + // directory: drop it, removing any copy that was previously rendered. + if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path) + else versions.delete(scope) + continue + } + const nextVersion: InstructionVersionState = { + path: file.displayPath, + version: probedFile.version, + digest: currentDigest, + trimmedDigest, + } + if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) { + versions.set(scope, nextVersion) + continue + } + const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' + const change: WorkspaceInstructionChange = { + action, + scope, + path: file.displayPath, + digest: currentDigest, + } + items.push({ change, file }) + versionUpdates.push({ change, state: nextVersion }) } - const nextVersion: InstructionVersionState = { - path: file.displayPath, - version: probedFile.version, - digest: currentDigest, - trimmedDigest, - } - if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) { - versions.set(scope, nextVersion) - continue - } - const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' - const change: WorkspaceInstructionChange = { - action, - scope, - path: file.displayPath, - digest: currentDigest, - } - items.push({ change, file }) - versionUpdates.push({ change, state: nextVersion }) } if (items.length === 0) return undefined const rendered = renderInstructionChanges(items, resolved.maxBytes) - if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined return { context: workspaceContextHook(rendered.text, rendered.changes), versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes), } } - -/** - * Validate a successful structured file touch and reconcile its applicable scopes. - * @param agent - optional agent attached to the tool execution. - * @param exec - completed tool execution descriptor. - * @param result - original tool result before post-execute decisions. - * @param resolved - normalized plugin configuration. - * @param pendingNestedChanges - per-session pending transition maps. - * @param baselineSessions - sessions whose configured baseline scopes should be probed. - * @param versionCache - per-session scope metadata used to skip unchanged reads. - * @param fileSystem - provider used for current file probes. - * @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls. - */ -export async function dynamicInstructionContext( - agent: Agent | undefined, - exec: ToolExecution, - result: ToolExecutionResult, - resolved: ResolvedConfig, - pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>, - baselineSessions: WeakSet<object>, - versionCache: InstructionVersionCache, - fileSystem: FileSystem, -): Promise<ReconciledInstructionContext | undefined> { - if (agent === undefined || result.isError) return undefined - const touchedPath = filePathFromExecution(exec) - if (touchedPath === undefined) return undefined - return reconcileInstructionContext( - agent, resolved, pendingNestedChanges, versionCache, fileSystem, - { - touchedPath, - includeBaselineScopes: baselineSessions.has(agent.session), - signal: exec.signal, - }, - ) -} diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 158d96f24d..ae5889b241 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -34,14 +34,13 @@ import { renderWorkspaceContext, } from '@deepseek-ai/dsh-workspace-context' import { + applyInstructionVersionUpdates, baselineInstructionState, - commitPendingInstructionContexts, - observeInstructionSessionEvent, - rollbackPendingInstructionChanges, + reconcileInstructionContext, type InstructionVersionCache, - type PendingInstructionChange, } from '../src/state.ts' -import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' +import { resolveConfig } from '../src/config.ts' +import { candidateScopeKey, renderInstructionChanges, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Per-candidate reconciliation scope key: directory paired with the file name. */ @@ -170,23 +169,20 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const id = SessionId('s1') - const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) + const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { ctx: new Context(), id: SessionId('a1'), options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('workspace-context must append directly to the open step') }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } @@ -202,9 +198,26 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? '' } -function workspaceContextOf(result: { additionalContexts?: UserMessage[] }): UserMessage | undefined { - return result.additionalContexts?.find(context => - context.source.kind === 'workspace-instructions') +async function workspaceContextOf(agent: Agent): Promise<UserMessage> { + return vi.waitFor(() => { + const context = agent.inbox.nextStep.find(message => + message.source.kind === 'workspace-instructions') + expect(context).toBeDefined() + return context! + }) +} + +async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> { + await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], + { turn: 1, step: 1, signal: testToolSignal }, + async () => ({ kind: 'enter' as const, messages: [] }), + ) +} + +async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise<UserMessage> { + await syncWorkspaceContext(ctx, agent) + return workspaceContextOf(agent) } function baselineEvents(agent: Agent): SessionEvent[] { @@ -214,20 +227,14 @@ function baselineEvents(agent: Agent): SessionEvent[] { && event.data.source.baseline === true) } -function workspaceChangeContext(scope: string, digest: string): UserMessage { - return createUserMessage({ - content: [{ type: 'text', text: `instructions for ${scope}` }], - source: { - kind: 'workspace-instructions', - changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], - }, - }) -} - -function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: UserMessage[] }): number | undefined { +async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise<number | undefined> { + await syncedWorkspaceContext(ctx, agent) let lastSeq: number | undefined - for (const context of result.additionalContexts ?? []) { - lastSeq = agent.session.append('user/message', context, { surfaceOp: 'append' }).seq + for (const claimed of agent.inbox.claim('next-step', 1)) { + if (claimed.source.kind !== 'workspace-instructions') continue + const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' }) + ctx.emit('session/event', agent.session, event) + lastSeq = event.seq } return lastSeq } @@ -235,7 +242,25 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: U const composedPrefixes = new WeakMap<object, Message[]>() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> { - await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000)) + const signal = AbortSignal.timeout(1000) + await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const claimed = agent.inbox.claim('next-step', 1) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + claimed, + { turn: 1, step: 2, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), + ) + const entered = decision.kind === 'enter' ? decision.messages : [] + for (const message of entered) { + const event = agent.session.append('user/message', message, { surfaceOp: 'append' }) + ctx.emit('session/event', agent.session, event) + } const prefix = agent.session.deriveMessages() composedPrefixes.set(agent, prefix) return prefix @@ -872,89 +897,6 @@ describe('workspace context request injection', () => { } }) - it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => { - const ctx = new Context() - try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) - - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ - signal: testToolSignal, - callId: CallId('no-fs-post-execute'), - name: 'read', - arguments: { file_path: join('pkg', 'file.txt') }, - agent: stubAgent('/virtual/repo'), - }), { - isError: false, - value: null, - content: [{ type: 'text', text: 'file content' }], - }, async () => ({ - kind: 'accept', - content: [{ type: 'text', text: 'downstream content' }], - })) - - expect(decision).toEqual({ kind: 'accept', content: [{ type: 'text', text: 'downstream content' }] }) - } finally { - await ctx.fiber.dispose() - } - }) - - it('does not load workspace instructions when a downstream listener blocks the tool call', async () => { - const root = await tempRepo() - const home = await tempRepo() - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) - fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) - await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - - const exec = stubToolExecution({ - signal: testToolSignal, - callId: CallId('read-blocked-post-execute'), - name: 'read', - arguments: { file_path: join('pkg', 'file.txt') }, - agent, - }) - const result = { - isError: false as const, - value: null, - content: [{ type: 'text' as const, text: 'hello' }], - } - - // A later PostToolUse-style policy blocks this otherwise-successful read. - const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ - kind: 'block' as const, - feedback: [{ type: 'text' as const, text: 'blocked by policy' }], - })) - - expect(blocked).toEqual({ - kind: 'block', - feedback: [{ type: 'text', text: 'blocked by policy' }], - }) - expect(blocked.additionalContexts).toBeUndefined() - - // The same read, when the downstream accepts, DOES surface the nested - // instructions — proving the block branch above is what suppressed them, - // and that the block did not consume the pending nested change. - const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ - kind: 'accept' as const, - })) - expect(accepted.kind).toBe('accept') - expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' }) - expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') - } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) - } - }) - it('contributes baseline instructions through durable injected history', async () => { const root = await tempRepo() const home = await tempRepo() @@ -974,6 +916,7 @@ describe('workspace context request injection', () => { role: 'user', source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }, @@ -993,7 +936,7 @@ describe('workspace context request injection', () => { } }) - it('injects one durable baseline contribution on the first step only', async () => { + it('queues and later commits one durable baseline contribution', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1015,6 +958,311 @@ describe('workspace context request injection', () => { } }) + it('reuses an inserted but unadmitted baseline after session recovery and plugin reload', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await agentEvents(ctx, original).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const inserted = original.inbox.nextStep[0] + expect(inserted?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true }) + + await fiber.dispose() + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const resumed = stubAgent(root, [...original.session.events]) + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + const claimed = resumed.inbox.claim('next-step', 1) + const decision = await agentEvents(ctx, resumed).waterfall( + 'agent/pre-step', + claimed, + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), + ) + if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') + for (const message of decision.messages) { + const event = resumed.session.append('user/message', message, { surfaceOp: 'append' }) + ctx.emit('session/event', resumed.session, event) + } + + expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) + expect(resumed.inbox.nextStep).toEqual([]) + expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.source.kind === 'workspace-instructions' + && message.source.baseline === true))).toHaveLength(1) + expect(baselineEvents(resumed)).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('replaces a recovered unadmitted baseline when its source changed offline', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'old repo rule') + const ctx = new Context() + const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await agentEvents(ctx, original).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const stale = original.inbox.nextStep[0] + expect(blocksText(stale?.content)).toContain('old repo rule') + + await write(join(root, 'AGENTS.md'), 'new repo rule') + await fiber.dispose() + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const resumed = stubAgent(root, [...original.session.events]) + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + const staleClaim = resumed.inbox.claim('next-step', 1) + const staleDecision = await agentEvents(ctx, resumed).waterfall( + 'agent/pre-step', + staleClaim, + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), + ) + + if (staleDecision.kind !== 'enter') throw new Error('recovered baseline was rejected') + expect(staleDecision.messages).toHaveLength(2) + expect(staleDecision.messages[0]).toBe(staleClaim[0]) + const replacement = staleDecision.messages[1] + expect(replacement?.id).not.toBe(stale?.id) + expect(blocksText(replacement?.content)).toContain('new repo rule') + expect(blocksText(replacement?.content)).not.toContain('old repo rule') + expect(resumed.inbox.nextStep).toHaveLength(0) + + for (const message of staleDecision.messages) { + const event = resumed.session.append('user/message', message, { surfaceOp: 'append' }) + ctx.emit('session/event', resumed.session, event) + } + expect(baselineEvents(resumed)).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it.each([ + { label: 'baseline loading is disabled', maxBytes: 0, provideFs: true }, + { label: 'the filesystem provider is unavailable', maxBytes: 65536, provideFs: false }, + ])('does not requeue recovered workspace contexts when $label', async ({ maxBytes, provideFs }) => { + const root = await tempRepo() + const home = await tempRepo() + const originalCtx = new Context() + const resumedCtx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await agentEvents(originalCtx, original).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const stale = original.inbox.nextStep[0] + expect(stale?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true }) + + await originalCtx.fiber.dispose() + if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) + await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) + const resumed = stubAgent(root, [...original.session.events]) + agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume') + const claimed = resumed.inbox.claim('next-step', 1) + const decision = await agentEvents(resumedCtx, resumed).waterfall( + 'agent/pre-step', + claimed, + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), + ) + + expect(claimed.map(message => message.id)).toEqual([stale?.id]) + expect(decision).toEqual({ kind: 'enter', messages: claimed }) + expect(resumed.inbox.nextStep).toEqual([]) + } finally { + await originalCtx.fiber.dispose() + await resumedCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('queues and records a removal for stale visible nested context', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'stale nested instructions' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }], + }, + }), { + surfaceOp: 'append', + }) + + await composeBaselinePrefix(ctx, agent) + + const removal = agent.session.events.find(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.changes.some(change => change.action === 'remove')) + expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], + }) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('combines startup reconciliation and baseline into one durable inbox context', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'stale nested instructions' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }], + }, + }), { + surfaceOp: 'append', + }) + + await composeBaselinePrefix(ctx, agent) + + const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions') + expect(workspaceEvents).toHaveLength(2) + expect(workspaceEvents.some(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.changes.some(change => change.action === 'remove'))).toBe(true) + expect(baselineEvents(agent)).toHaveLength(1) + + await composeBaselinePrefix(ctx, agent) + expect(agent.session.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions')).toHaveLength(2) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('enters the baseline right after the claimed prompt in the first pre-step without queuing another step', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const prompt = createUserMessage({ + content: [{ type: 'text', text: 'current prompt' }], + source: { kind: 'user' }, + }) + const downstream = { kind: 'enter' as const, messages: [prompt] } + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [prompt], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve(downstream), + ) + + expect(decision).toMatchObject({ kind: 'enter' }) + if (decision.kind !== 'enter') throw new Error('workspace baseline was rejected') + expect(decision.messages).toHaveLength(2) + expect(decision.messages[0]).toBe(prompt) + expect(decision.messages[1]?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true }) + expect(blocksText(decision.messages[1]?.content)).toContain('Instructions from: AGENTS.md') + expect(agent.inbox.nextStep).toHaveLength(0) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('reports a changed user-global instruction through the visible-scope reconcile path', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + await write(join(home, 'AGENTS.md'), 'global rule') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + + await write(join(home, 'AGENTS.md'), 'updated global rule') + await syncWorkspaceContext(ctx, agent) + + const pending = await workspaceContextOf(agent) + expect(pending?.source).toMatchObject({ + kind: 'workspace-instructions', + changes: [{ action: 'replace', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }], + }) + expect(blocksText(pending?.content)).toContain('updated global rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('queues the desired workspace context when the current step is rejected', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const downstream = { kind: 'reject' as const } + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve(downstream), + ) + + expect(decision).toBe(downstream) + expect(agent.inbox.nextStep).toHaveLength(1) + expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1036,14 +1284,14 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await write(join(root, 'AGENTS.md'), 'updated repo rule') - const update = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-remount'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(update)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) } finally { @@ -1112,8 +1360,9 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) - expect(baselines).toHaveLength(2) - const latest = baselines.at(-1) + expect(baselines).toHaveLength(1) + const latest = resumed.session.events.findLast(event => + event.type === 'user/message' && event.data.source.kind === 'workspace-instructions') expect(latest?.type === 'user/message' && blocksText(latest.data.content)) .toContain('new root rule after offline edit') const original0 = baselines[0] @@ -1147,7 +1396,7 @@ describe('workspace context request injection', () => { } }) - it('places workspace instructions before later step contributors such as a skills catalog', async () => { + it('keeps an independent pre-step contribution after the queued workspace context', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1155,8 +1404,16 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - ctx.on('agent/step', (agent) => { - agent.inject(createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } })) + ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + ...decision, + messages: [ + ...decision.messages, + createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } }), + ], + } }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -1170,7 +1427,7 @@ describe('workspace context request injection', () => { } }) - it('appends a replacement when a frozen baseline file changes before a later fs tool call', async () => { + it('queues a replacement when a frozen baseline file changes before a later fs tool call', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1183,23 +1440,23 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') - expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('Updated instructions from: AGENTS.md') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('new root rule with more detail') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) - it('appends a removal when a frozen baseline file is deleted before a later fs tool call', async () => { + it('queues a removal when a frozen baseline file is deleted before a later fs tool call', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1212,15 +1469,15 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('Instructions removed: AGENTS.md') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1232,19 +1489,13 @@ describe('workspace context request injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'shared root and global rule') - await write(join(root, 'file.txt'), 'hello') const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 }) + await mountWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) - const result = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent, - }) expect(derivedText(agent).match(/shared root and global rule/g)).toHaveLength(1) - expect(result.additionalContexts).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) } @@ -1404,7 +1655,7 @@ describe('workspace context request injection', () => { const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) expect(prefix).toEqual([]) - expect(fs.readTargets).toEqual([instructionPath]) + expect(fs.readTargets).toEqual([instructionPath, instructionPath]) expect(fs.readTextTargets).toEqual([]) } finally { await ctx.fiber.dispose() @@ -1413,7 +1664,7 @@ describe('workspace context request injection', () => { } }) - it('aborts an in-flight baseline stream with the step signal', async () => { + it('aborts an in-flight baseline stream with the prompt signal', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') const ctx = new Context() @@ -1425,7 +1676,12 @@ describe('workspace context request injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') - const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal) + const pending = agentEvents(ctx, stubAgent(root)).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: controller.signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) await fs.started.promise controller.abort(reason) @@ -1655,7 +1911,7 @@ describe('workspace context request injection', () => { } }) - it('cleans up its agent/step listener when the plugin fiber is disposed', async () => { + it('cleans up its pre-step listener when the plugin fiber is disposed', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1812,7 +2068,7 @@ describe('workspace context request injection', () => { }) describe('dynamic nested workspace context injection', () => { - it('commits a buffered instruction change before a later tool abort closes the step', async () => { + it('projects a successful file result even when a later sibling aborts the step', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -1862,11 +2118,9 @@ describe('dynamic nested workspace context injection', () => { await agent.whenIdle() const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') - // Cancellation discards the aborted step's pending context. The next - // successful read discovers and durably injects it once. expect(contexts).toHaveLength(1) expect(adapter.requests).toHaveLength(3) - expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n')) + expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n')) .toContain('nested rule survives an aborted tool batch') } finally { await ctx.fiber.dispose() @@ -1891,7 +2145,23 @@ describe('dynamic nested workspace context injection', () => { expect(state.versions).toEqual(new Map()) }) - it('propagates the tool execution signal into dynamic filesystem reconciliation', async () => { + it('creates and releases version-cache state only for non-empty updates', () => { + const agent = stubAgent('/repo') + const cache: InstructionVersionCache = new WeakMap() + const change = { action: 'set' as const, scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md', digest: 'digest' } + applyInstructionVersionUpdates(agent.session, [], cache) + expect(cache.get(agent.session)).toBeUndefined() + + applyInstructionVersionUpdates(agent.session, [{ + change, + state: { path: 'AGENTS.md', version: FsVersion('v1'), digest: 'digest', trimmedDigest: 'trimmed' }, + }], cache) + expect(cache.get(agent.session)?.has(change.scope)).toBe(true) + applyInstructionVersionUpdates(agent.session, [{ change: { ...change, action: 'remove' } }], cache) + expect(cache.get(agent.session)).toBeUndefined() + }) + + it('does not refresh dynamic instructions after the tool signal is aborted', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') const ctx = new Context() @@ -1912,14 +2182,15 @@ describe('dynamic nested workspace context injection', () => { signal: controller.signal, }) - const pending = ctx.waterfall('tools/post-execute', exec, { + ctx.emit('tools/result', exec, { content: [{ type: 'text', text: 'ok' }], isError: false, value: null, - }, () => Promise.resolve({ kind: 'accept' as const })) + }) - await expect(pending).rejects.toBe(reason) - expect(fs.signals).toContain(controller.signal) + await Promise.resolve() + expect(fs.signals).toEqual([]) + expect(exec.agent?.inbox.nextStep).toEqual([]) } finally { await ctx.fiber.dispose() await rm(dirname(root), { recursive: true, force: true }) @@ -1948,41 +2219,60 @@ describe('dynamic nested workspace context injection', () => { }) expect(result.isError).toBe(false) - expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) - expect(workspaceContextOf(result)?.source).toMatchObject({ - kind: 'workspace-instructions', - changes: [{ - action: 'set', - scope: sk('pkg', 'AGENTS.md'), - path: join('pkg', 'AGENTS.md'), - }], - }) - const source = workspaceContextOf(result)?.source + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' }) + const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source + expect(queuedSource).toMatchObject({ kind: 'workspace-instructions', form: 'instructions' }) + expect(queuedSource.kind === 'workspace-instructions' && queuedSource.changes.some(change => + change.action === 'set' + && change.scope === sk('pkg', 'AGENTS.md') + && change.path === join('pkg', 'AGENTS.md'))).toBe(true) + const source = ((await syncedWorkspaceContext(ctx, agent))).source const firstChange = source?.kind === 'workspace-instructions' - ? source.changes[0] + ? source.changes.find(change => change.scope === sk('pkg', 'AGENTS.md')) : undefined const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) ? firstChange.digest : undefined expect(changeDigest).toMatch(/^[a-f0-9]{40}$/) - const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toBe([ - '<system-reminder>', - `Additional instructions from: ${join('pkg', 'AGENTS.md')}`, - '', - 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', - '', - 'nested package rule', - '</system-reminder>', - ].join('\n')) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) + expect(text).toContain('nested package rule') expect(text).not.toContain('<workspace-context') - expect(text).not.toContain('baseline root rule') + expect(text).toContain('baseline root rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) + it('finishes a committed file-result projection after the tool signal ends', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const controller = new AbortController() + + ctx.emit('tools/result', stubToolExecution({ + signal: controller.signal, + callId: CallId('read-before-signal-end'), + name: 'read', + arguments: { file_path: join('pkg', 'file.txt') }, + agent, + }), { content: [{ type: 'text', text: 'ok' }], isError: false, value: null }) + controller.abort(new Error('tool execution ended')) + + expect(blocksText((await workspaceContextOf(agent)).content)).toContain('nested package rule') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('loads every configured instruction candidate present in a nested scope', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1997,16 +2287,17 @@ describe('dynamic nested workspace context injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], }) + const agent = stubAgent(root) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-configured-nested-candidate'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) - const text = blocksText(workspaceContextOf(result)?.content) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`) expect(text).toContain('local package rule') expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) @@ -2029,16 +2320,17 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-nested-overlay'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) - const source = workspaceContextOf(result)?.source + const source = ((await syncedWorkspaceContext(ctx, agent))).source const changes = source?.kind === 'workspace-instructions' ? source.changes : [] @@ -2046,7 +2338,7 @@ describe('dynamic nested workspace context injection', () => { expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }), expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.local.md') }), ])) - const text = blocksText(workspaceContextOf(result)?.content) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(text).toContain('nested base rule') expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.local.md')}`) @@ -2071,16 +2363,17 @@ describe('dynamic nested workspace context injection', () => { maxBytes: 65536, localInstructionFileCandidates: [], }) + const agent = stubAgent(root) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-nested-overlay-disabled'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) - const text = blocksText(workspaceContextOf(result)?.content) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(text).not.toContain(join('pkg', 'AGENTS.local.md')) } finally { @@ -2107,6 +2400,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) + await appendAdditionalContexts(ctx, agent) const second = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-nested-2'), @@ -2115,8 +2409,9 @@ describe('dynamic nested workspace context injection', () => { agent, }) - expect(first.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() expect(second.additionalContexts).toBeUndefined() + expect(agent.inbox.nextStep).toEqual([]) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2145,13 +2440,13 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) const second = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(first.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() expect(second.additionalContexts).toBeUndefined() expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(1) } finally { @@ -2178,16 +2473,17 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) const afterVersionChange = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) const afterRefresh = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, @@ -2195,7 +2491,9 @@ describe('dynamic nested workspace context injection', () => { expect(afterVersionChange.additionalContexts).toBeUndefined() expect(afterRefresh.additionalContexts).toBeUndefined() - expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + await vi.waitFor(() => { + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + }) } finally { await ctx.fiber.dispose() await rm(dirname(root), { recursive: true, force: true }) @@ -2219,17 +2517,21 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(ToolFs) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const firstAgent = stubAgent(root) + const secondAgent = stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root), + callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent, }) const second = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root), + callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: secondAgent, }) - expect(first.additionalContexts).toBeDefined() - expect(second.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() + expect(second.additionalContexts).toBeUndefined() + expect(((await syncedWorkspaceContext(ctx, firstAgent))).source.kind).toBe('workspace-instructions') + expect(((await syncedWorkspaceContext(ctx, secondAgent))).source.kind).toBe('workspace-instructions') expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) } finally { await ctx.fiber.dispose() @@ -2249,22 +2551,23 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') - const changed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(changed)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([ '<system-reminder>', `Updated instructions from: ${join('pkg', 'AGENTS.md')}`, '', @@ -2291,26 +2594,26 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - const firstText = blocksText(workspaceContextOf(first)?.content) + const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('native package rule') expect(firstText).toContain('sibling package rule') - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - const removed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) // Removing one candidate only removes its own scope; the sibling scope is untouched. - expect(workspaceContextOf(removed)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) - expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).not.toContain('sibling package rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2329,15 +2632,15 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - expect(workspaceContextOf(result)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - const text = blocksText(workspaceContextOf(result)?.content) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(text.match(/nested rule/g)).toHaveLength(1) expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(text).not.toContain(join('pkg', 'CLAUDE.md')) @@ -2347,44 +2650,121 @@ describe('dynamic nested workspace context injection', () => { } }) - it('keeps deduplicating against a loaded candidate whose probe transiently fails', async () => { - const root = await tempRepo() - const home = await tempRepo() + it.each(['visible', 'claimed'] as const)( + 'keeps unavailable active candidate groups unchanged with cold and warm caches when authority is $s', + async (authority) => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' }) + fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) + const agent = stubAgent(root) + const agentsScope = sk('pkg', 'AGENTS.md') + const loaded = baselineInstructionState([{ + absolutePath: join(root, 'pkg/AGENTS.md'), + displayPath: join('pkg', 'AGENTS.md'), + content: 'nested rule', + version: FsVersion('loaded-agents'), + }]) + const previous = loaded.changes.get(agentsScope) + if (previous === undefined) throw new Error('missing AGENTS.md baseline state') + const authoritative = createUserMessage({ + content: [{ type: 'text', text: 'nested rule' }], + source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] }, + }) + if (authority === 'visible') { + agent.session.append('user/message', authoritative, { surfaceOp: 'append' }) + } + const authorityMessages = authority === 'claimed' ? [authoritative] : [] + + for (const instructionFileCandidates of [ + ['AGENTS.md', 'CLAUDE.md'], + ['CLAUDE.md', 'AGENTS.md'], + ]) { + const resolved = resolveConfig({ + dshHome: home, + maxBytes: 65536, + instructionFileCandidates, + localInstructionFileCandidates: [], + }) + const coldCache: InstructionVersionCache = new WeakMap() + const warmCache: InstructionVersionCache = new WeakMap() + warmCache.set(agent.session, new Map(loaded.versions)) + const options = { + authorityMessages, + scopeMessages: [createUserMessage({ + content: [{ type: 'text', text: 'pending baseline duplicate' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }, + })], + touchedPaths: [], + includeBaselineScopes: false, + signal: testToolSignal, + } + + const cold = await reconcileInstructionContext(agent, resolved, coldCache, fs, options) + const warm = await reconcileInstructionContext(agent, resolved, warmCache, fs, options) + + expect(cold).toEqual(warm) + expect(cold).toBeUndefined() + } + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }, + ) + + it('skips visible baseline scopes when baseline scopes are excluded from reconciliation', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') const ctx = new Context() try { - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested rule' }) - fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) - await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) const agent = stubAgent(root) - - const first = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-before-transient-probe-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + const rootScope = sk('.', 'AGENTS.md') + const loaded = baselineInstructionState([{ + absolutePath: join(root, 'AGENTS.md'), + displayPath: 'AGENTS.md', + content: 'repo rule', + version: FsVersion('loaded-agents'), + }]) + const previous = loaded.changes.get(rootScope) + if (previous === undefined) throw new Error('missing AGENTS.md baseline state') + const authoritative = createUserMessage({ + content: [{ type: 'text', text: 'repo rule' }], + source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] }, }) - appendAdditionalContexts(agent, first) - expect(first.additionalContexts).toBeDefined() - - // The loaded candidate's probe fails while an identical sibling appears: - // the cached candidate stays effective (last good state), so the sibling - // must still deduplicate against it rather than land as a duplicate set. - fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' }) - const duringFailure = await ctx.tools.execute({ + agent.session.append('user/message', authoritative, { surfaceOp: 'append' }) + const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] }) + const cache: InstructionVersionCache = new WeakMap() + cache.set(agent.session, new Map(loaded.versions)) + const options = { + authorityMessages: [], + scopeMessages: [], + touchedPaths: [], + includeBaselineScopes: false, signal: testToolSignal, - callId: CallId('read-during-transient-probe-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, - }) + } - expect(duringFailure.additionalContexts).toBeUndefined() + const result = await reconcileInstructionContext(agent, resolved, cache, fs, options) + + expect(result).toBeUndefined() } finally { await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) } }) @@ -2400,24 +2780,25 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - const firstText = blocksText(workspaceContextOf(first)?.content) + const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('canonical nested rule') expect(firstText).toContain('divergent nested rule') - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') - const converged = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.source).toMatchObject({ + const convergence = await syncedWorkspaceContext(ctx, agent) + expect(convergence.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }], }) - expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) + expect(blocksText(convergence.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2436,25 +2817,25 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it. await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule') - const converged = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [ { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }, ], }) - const text = blocksText(workspaceContextOf(converged)?.content) + const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(text).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) expect(text).toContain(`Updated instructions from: ${join('pkg', 'AGENTS.md')}`) } finally { @@ -2474,22 +2855,23 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - const removed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.source).toEqual({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([ '<system-reminder>', `Instructions removed: ${join('pkg', 'AGENTS.md')}`, '', @@ -2513,12 +2895,13 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) - expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule') + const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) + await appendAdditionalContexts(ctx, agent) + expect(firstText).toContain('package rule') // The candidate now resolves through a symlink to a directory. A non-file // target is a confirmed absence (not unavailable), so the loaded scope is @@ -2526,15 +2909,15 @@ describe('dynamic nested workspace context injection', () => { await rm(join(root, 'pkg/AGENTS.md')) await mkdir(join(root, 'pkg/elsewhere'), { recursive: true }) await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md')) - const removed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2552,29 +2935,29 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - const removed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, removed) + await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') - const restored = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(restored)?.source).toMatchObject({ + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) - expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('restored package rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2601,14 +2984,14 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) const duringFailure = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(first.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() expect(duringFailure.additionalContexts).toBeUndefined() } finally { await ctx.fiber.dispose() @@ -2634,7 +3017,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + await appendAdditionalContexts(ctx, agent) const resumed = stubAgent(root, [...agent.session.events]) const afterResume = await ctx.tools.execute({ @@ -2645,7 +3028,7 @@ describe('dynamic nested workspace context injection', () => { agent: resumed, }) - expect(first.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() expect(afterResume.additionalContexts).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) @@ -2663,11 +3046,11 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original, }) - appendAdditionalContexts(original, first) + await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') const resumed = stubAgent(root, [...original.session.events]) @@ -2701,7 +3084,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - const contextSeq = appendAdditionalContexts(agent, first)! + const contextSeq = (await appendAdditionalContexts(ctx, agent))! const visibleBeforeCompact = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-while-visible'), @@ -2726,10 +3109,10 @@ describe('dynamic nested workspace context injection', () => { agent, }) - expect(first.additionalContexts).toBeDefined() + expect(first.additionalContexts).toBeUndefined() expect(visibleBeforeCompact.additionalContexts).toBeUndefined() - expect(afterCompact.additionalContexts).toBeDefined() - expect(blocksText(workspaceContextOf(afterCompact)?.content)).toContain('nested package rule') + expect(afterCompact.additionalContexts).toBeUndefined() + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2765,14 +3148,15 @@ describe('dynamic nested workspace context injection', () => { sourceEventSeqs: [baseline!.seq], }) - const rearmed = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-compacted-baseline'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - appendAdditionalContexts(agent, rearmed) + const rearmedContext = (await syncedWorkspaceContext(ctx, agent)) + await appendAdditionalContexts(ctx, agent) const afterRearm = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-rearmed-baseline'), @@ -2782,10 +3166,10 @@ describe('dynamic nested workspace context injection', () => { }) expect(whileVisible.additionalContexts).toBeUndefined() - expect(workspaceContextOf(rearmed)?.source).toMatchObject({ + expect(rearmedContext.source).toMatchObject({ changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - expect(blocksText(workspaceContextOf(rearmed)?.content)).toContain('root rule') + expect(blocksText(rearmedContext.content)).toContain('root rule') expect(afterRearm.additionalContexts).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) @@ -2805,16 +3189,17 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-package'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) + await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-subtree'), name: 'read', @@ -2822,8 +3207,8 @@ describe('dynamic nested workspace context injection', () => { agent, }) - expect(blocksText(workspaceContextOf(first)?.content)).toContain('package note') - expect(blocksText(workspaceContextOf(second)?.content)).toContain('subtree rule') + expect(firstText).toContain('package note') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('subtree rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2842,16 +3227,17 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) const agent = stubAgent(root) - const first = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-subtree-omitting-parent'), name: 'read', arguments: { file_path: join('pkg', 'sub', 'file.txt') }, agent, }) - appendAdditionalContexts(agent, first) + const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) + await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-parent-after-omit'), name: 'read', @@ -2859,11 +3245,9 @@ describe('dynamic nested workspace context injection', () => { agent, }) - const firstText = blocksText(workspaceContextOf(first)?.content) - expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`) - expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`) + expect(firstText).toContain(join('pkg', 'AGENTS.md')) expect(firstText).toContain('subtree rule') - expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('parent rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2887,6 +3271,7 @@ describe('dynamic nested workspace context injection', () => { ], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, @@ -2904,7 +3289,7 @@ describe('dynamic nested workspace context injection', () => { source: { kind: 'plugin', plugin: 'other' }, }), { surfaceOp: 'append' }) - const result = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-spoofed-state'), name: 'read', @@ -2912,7 +3297,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) - expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2938,7 +3323,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: 'root.txt' }, agent, }) - const absoluteResult = await ctx.tools.execute({ + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-absolute-nested-file'), name: 'read', @@ -2947,51 +3332,13 @@ describe('dynamic nested workspace context injection', () => { }) expect(rootResult.additionalContexts).toBeUndefined() - expect(blocksText(workspaceContextOf(absoluteResult)?.content)).toContain('nested package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) - it('treats a reconciliation provider failure as unavailable and a resolved non-file as absent', async () => { - const root = await tempRepo() - const home = await tempRepo() - const ctx = new Context() - try { - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - const result = { - callId: CallId('provider-probe-result'), - content: [{ type: 'text' as const, text: 'ok' }], - isError: false as const, - value: null, - } - - const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ - signal: testToolSignal, - callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, - }), result, async () => ({ kind: 'accept' as const })) - fs.throwOnStat.clear() - fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) - const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ - signal: testToolSignal, - callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, - }), result, async () => ({ kind: 'accept' as const })) - - expect(failedStat).toEqual({ kind: 'accept' }) - expect(mismatchedStat).toEqual({ kind: 'accept' }) - } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) - } - }) - it('skips unreadable nested instruction files without attaching empty context', async () => { // Cross-platform unreadable fixture: the provider read throws (chmod 0 // cannot make a file unreadable to its owner on Windows). @@ -3010,18 +3357,22 @@ describe('dynamic nested workspace context injection', () => { fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-with-unreadable-nested-instruction'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) + await syncWorkspaceContext(ctx, agent) expect(result.isError).toBe(false) expect(result.additionalContexts).toBeUndefined() - expect(fs.readTargets).toContain(nested) + await vi.waitFor(() => { + expect(fs.readTargets).toContain(nested) + }) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) @@ -3029,7 +3380,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('preserves a downstream canonical value replacement and keeps contexts separate', async () => { + it('preserves a downstream canonical value replacement while queuing workspace context separately', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -3038,6 +3389,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { @@ -3057,7 +3409,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-with-downstream'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) expect(result.isError).toBe(false) @@ -3069,24 +3421,21 @@ describe('dynamic nested workspace context injection', () => { totalLines: 1, }) expect(blocksText(result.content)).toContain('downstream replacement') - expect(result.additionalContexts).toHaveLength(2) - expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) - expect(workspaceContextOf(result)?.source).toMatchObject({ + expect(result.additionalContexts).toHaveLength(1) + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' }) + expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') - expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context') - expect(result.additionalContexts?.[1]).toEqual({ + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).not.toContain('downstream context') + expect(result.additionalContexts?.[0]).toEqual({ id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'downstream context' }], source: { kind: 'plugin', plugin: 'downstream' }, }) - const agent = stubAgent(root) - appendAdditionalContexts(agent, result) - expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context') - expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain('<context source=') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -3102,6 +3451,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked downstream' }], @@ -3112,7 +3462,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-blocked-downstream'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) // The pipeline rejected this touch, so no workspace instructions from it @@ -3120,13 +3470,15 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(true) expect(blocksText(result.content)).toBe('blocked downstream') expect(result.additionalContexts).toBeUndefined() + await syncWorkspaceContext(ctx, agent) + expect(agent.inbox.nextStep).toEqual([]) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) - it('does not commit pending state when an outer post-execute listener blocks the final result', async () => { + it('does not project a file touch when an outer post-execute listener blocks the final result', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -3155,6 +3507,9 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) + expect(agent.inbox.nextStep).toEqual([]) + shouldBlock = false const accepted = await ctx.tools.execute({ signal: testToolSignal, @@ -3167,7 +3522,7 @@ describe('dynamic nested workspace context injection', () => { expect(blocked.isError).toBe(true) expect(blocked.additionalContexts).toBeUndefined() expect(accepted.isError).toBe(false) - expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) @@ -3175,7 +3530,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('rolls back parent-token pending state when a composite result is blocked', async () => { + it('projects a successful nested file result independently of a blocked composite result', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -3205,10 +3560,9 @@ describe('dynamic nested workspace context injection', () => { return nested.content }, })) - let shouldBlock = true ctx.on('tools/post-execute', async (exec, _result, next) => { const downstream = await next() - return exec.name === 'composite-read' && shouldBlock + return exec.name === 'composite-read' ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) @@ -3219,16 +3573,10 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent, }) - shouldBlock = false - const accepted = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent, - }) expect(blocked.isError).toBe(true) expect(blocked.additionalContexts).toBeUndefined() - expect(accepted.isError).toBe(false) - expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') + expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) @@ -3236,81 +3584,81 @@ describe('dynamic nested workspace context injection', () => { } }) - it('handles defensive tools/result observer branches without retaining staged state', async () => { + it('ignores failed, aborted, agentless, and non-file final results', async () => { const ctx = new Context() try { + await ctx.plugin(RecordingFileSystem) await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') - const parent = Symbol('parent') as ToolExecutionToken const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } + const aborted = new AbortController() + aborted.abort(new Error('cancelled')) ctx.emit('tools/result', stubToolExecution({ - signal: testToolSignal, - callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, + signal: testToolSignal, callId: CallId('agentless'), name: 'read', arguments: { file_path: 'file.txt' }, + }), plainResult) + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, callId: CallId('failed'), name: 'read', arguments: { file_path: 'failed/file.txt' }, agent, + }), { content: [], isError: true, error: { message: 'failed' } }) + ctx.emit('tools/result', stubToolExecution({ + signal: aborted.signal, callId: CallId('aborted'), name: 'read', arguments: { file_path: 'aborted/file.txt' }, agent, }), plainResult) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, - callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, - }), { ...plainResult, additionalContexts: [createUserMessage({ - content: [], source: { kind: 'plugin', plugin: 'workspace-context' }, - })] }) + callId: CallId('null-arguments'), name: 'read', arguments: null, agent, + }), plainResult) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, - callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, - }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) + callId: CallId('missing-path'), name: 'read', arguments: {}, agent, + }), plainResult) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, - callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, - }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) - ctx.emit('tools/result', { - ...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), - token: parent, - }, plainResult) + callId: CallId('non-string-path'), name: 'read', arguments: { file_path: 1 }, agent, + }), plainResult) + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, + callId: CallId('blank-path'), name: 'read', arguments: { file_path: ' ' }, agent, + }), plainResult) + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, + callId: CallId('non-fs'), name: 'composite', arguments: {}, agent, + }), plainResult) - expect(agent.session.deriveMessages()).toEqual([]) + await Promise.resolve() + expect(fs.signals).toEqual([]) + expect(agent.inbox.nextStep).toEqual([]) } finally { await ctx.fiber.dispose() } }) - it('ignores post-execute events that are not successful structured file touches', async () => { - const root = await tempRepo() - const home = await tempRepo() + it('warns when an asynchronous file-result projection fails', async () => { + const ctx = new Context() try { - await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') - await write(join(root, 'pkg/deep/file.txt'), 'hello') - const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - const result = { - callId: CallId('manual'), - content: [{ type: 'text' as const, text: 'manual result' }], - isError: false as const, - value: null, - } - const cases = [ - { name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: undefined }, - { name: 'bash', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent }, - { name: 'read', arguments: null, agent }, - { name: 'read', arguments: {}, agent }, - { name: 'read', arguments: { file_path: 1 }, agent }, - { name: 'read', arguments: { file_path: ' ' }, agent }, - ] + await ctx.plugin(RecordingFileSystem) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const fs = ctx.fs as RecordingFileSystem + const agent = stubAgent('/') + const failure = new Error('projection failed') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + fs.entries.set('/.git', { type: 'directory' }) + fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' }) + vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure }) - for (const item of cases) { - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ - signal: testToolSignal, - callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), - name: item.name, - arguments: item.arguments, - ...item.agent === undefined ? {} : { agent: item.agent }, - }), result, async () => ({ kind: 'accept' as const })) - expect(decision).toEqual({ kind: 'accept' }) - } + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, + callId: CallId('projection-failure'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent, + }), { content: [], isError: false, value: null }) + + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure) + }) } finally { - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) + await ctx.fiber.dispose() } }) @@ -3365,7 +3713,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('cleans up its tools/post-execute listener when the plugin fiber is disposed', async () => { + it('cleans up its tools/result listener when the plugin fiber is disposed', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -3375,17 +3723,20 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await fiber.dispose() + const agent = stubAgent(root) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-after-dispose'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent, }) expect(result.isError).toBe(false) expect(result.additionalContexts).toBeUndefined() + await Promise.resolve() + expect(agent.inbox.nextStep).toEqual([]) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -3393,141 +3744,258 @@ describe('dynamic nested workspace context injection', () => { }) }) -describe('workspace context pending state', () => { - it('leaves pending transitions from other or untracked steps untouched', () => { - const agent = stubAgent('/') - const change = (scope: string) => ({ - action: 'set' as const, scope, path: `${scope}/AGENTS.md`, digest: scope, - }) - const pending = new WeakMap<object, Map<string, PendingInstructionChange>>([[ - agent.session, - new Map([ - ['untracked', { change: change('untracked'), afterSeq: 0 }], - ['other-turn', { change: change('other-turn'), afterSeq: 0, step: { turn: 2, step: 1 } }], - ['other-step', { change: change('other-step'), afterSeq: 0, step: { turn: 1, step: 2 } }], - ['current', { change: change('current'), afterSeq: 0, step: { turn: 1, step: 1 } }], - ]), - ]]) - const versions: InstructionVersionCache = new WeakMap() - const ended = agent.session.append('step/end', { turn: 1, step: 1 }) +describe('workspace context inbox synchronization', () => { + const acceptedResult = { + content: [{ type: 'text' as const, text: 'ok' }], + isError: false as const, + value: null, + } - observeInstructionSessionEvent(agent.session, ended, pending, versions) + it('keeps one reusable desired context when recovery contains an exact duplicate', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'duplicate baseline') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await syncWorkspaceContext(ctx, agent) + const desired = agent.inbox.nextStep[0]! + agent.inbox.append('next-step', createUserMessage({ content: desired.content, source: desired.source })) - expect([...pending.get(agent.session)?.keys() ?? []]).toEqual(['untracked', 'other-turn', 'other-step']) + await syncWorkspaceContext(ctx, agent) + + expect(agent.inbox.nextStep).toHaveLength(1) + expect(agent.inbox.nextStep[0]?.id).toBe(desired.id) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } }) - it('confirms a pending transition only when its matching workspace context reaches the log', () => { - const agent = stubAgent('/') - const pending = new WeakMap<object, Map<string, PendingInstructionChange>>() - const versions: InstructionVersionCache = new WeakMap() - const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) - expect(change).toBeDefined() - versions.set(agent.session, new Map([['pkg', { - path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', - }]])) + it('keeps a dynamic change within a one-byte positive render budget', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) + const agent = stubAgent(root) + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, + callId: CallId('tiny-budget-touch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }), acceptedResult) - const unrelated = agent.session.append('user/message', createUserMessage({ - content: [], source: { kind: 'plugin', plugin: 'other' }, - }), { surfaceOp: 'append' }) - observeInstructionSessionEvent(agent.session, unrelated, pending, versions) - expect(pending.get(agent.session)?.has('pkg')).toBe(true) + await syncWorkspaceContext(ctx, agent) - const otherContext = workspaceChangeContext('other', 'other') - const otherWorkspaceEvent = agent.session.append('user/message', createUserMessage({ - content: otherContext.content, - source: otherContext.source, - }), { surfaceOp: 'append' }) - observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) - expect(pending.get(agent.session)?.has('pkg')).toBe(true) - - const context = workspaceChangeContext('pkg', 'one') - const confirmed = agent.session.append('user/message', createUserMessage({ - content: context.content, - source: context.source, - }), { surfaceOp: 'append' }) - observeInstructionSessionEvent(agent.session, confirmed, pending, versions) - - expect(pending.has(agent.session)).toBe(false) - expect(versions.get(agent.session)?.has('pkg')).toBe(true) + expect(agent.inbox.nextStep).toHaveLength(1) + expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } }) - it('discards pending state and its version fast path when the owning step closes first', () => { - const agent = stubAgent('/') - const pending = new WeakMap<object, Map<string, PendingInstructionChange>>() - const versions: InstructionVersionCache = new WeakMap() - agent.session.append('step/start', { turn: 1, step: 1 }) - commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) - versions.set(agent.session, new Map([['pkg', { - path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', - }]])) + it('settles same-scope replacement and deletion against files instead of pending prose', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'pending version one') + await write(join(root, 'pkg/file.txt'), 'file') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + await syncWorkspaceContext(ctx, agent) + expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('pending version one') + const duplicate = createUserMessage({ + content: agent.inbox.nextStep[0]!.content, + source: agent.inbox.nextStep[0]!.source, + }) + agent.inbox.append('next-step', duplicate) - const ended = agent.session.append('step/end', { turn: 1, step: 1 }) - observeInstructionSessionEvent(agent.session, ended, pending, versions) + await write(join(root, 'pkg/AGENTS.md'), 'pending version two with more detail') + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('pending-v2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + await syncWorkspaceContext(ctx, agent) + expect(agent.inbox.nextStep).toHaveLength(1) + expect(agent.inbox.nextStep[0]?.id).not.toBe(duplicate.id) + expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('pending version two with more detail') + expect(blocksText(agent.inbox.nextStep[0]?.content)).not.toContain('pending version one') - expect(pending.has(agent.session)).toBe(false) - expect(versions.has(agent.session)).toBe(false) + await rm(join(root, 'pkg/AGENTS.md')) + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('pending-delete'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + await syncWorkspaceContext(ctx, agent) + expect(agent.inbox.nextStep).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } }) - it('keeps an unrelated scope\'s version fast path when a step-close discard empties only its own scope', () => { - const agent = stubAgent('/') - const pending = new WeakMap<object, Map<string, PendingInstructionChange>>() - const versions: InstructionVersionCache = new WeakMap() - agent.session.append('step/start', { turn: 1, step: 1 }) - commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) - versions.set(agent.session, new Map([ - ['pkg', { - path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', - }], - ['other', { - path: join('other', 'AGENTS.md'), version: FsVersion('v2'), digest: 'two', trimmedDigest: 'two', - }], - ])) + it('keeps a completed tool projection when a later pre-step aborts', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) + fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const first = stubToolExecution({ + signal: testToolSignal, + callId: CallId('projected-before-abort'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent, + }) + ctx.emit('tools/result', first, acceptedResult) + const controller = new AbortController() + controller.abort(new Error('abort pre-step reconciliation')) - const ended = agent.session.append('step/end', { turn: 1, step: 1 }) - observeInstructionSessionEvent(agent.session, ended, pending, versions) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], + { turn: 1, step: 1, signal: controller.signal }, + async () => ({ kind: 'enter' as const, messages: [] }), + )).rejects.toThrow('abort pre-step reconciliation') - expect(pending.has(agent.session)).toBe(false) - expect(versions.get(agent.session)?.has('pkg')).toBe(false) - expect(versions.get(agent.session)?.has('other')).toBe(true) + ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, + callId: CallId('projected-after-abort'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent, + }), acceptedResult) + await syncWorkspaceContext(ctx, agent) + const text = blocksText(agent.inbox.nextStep[0]?.content) + expect(text).toContain('restored A') + expect(text).toContain('restored B') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } }) - it('rolls back only the exact current transition and releases empty session state', () => { - const agent = stubAgent('/') - const pending = new WeakMap<object, Map<string, PendingInstructionChange>>() + it('serializes concurrent final results and merges both touched scopes into one pending context', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) + fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const first = stubToolExecution({ + signal: testToolSignal, + callId: CallId('concurrent-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent, + }) + const second = stubToolExecution({ + signal: testToolSignal, + callId: CallId('concurrent-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent, + }) - rollbackPendingInstructionChanges(agent, [{ - action: 'set', scope: 'missing', path: 'missing/AGENTS.md', digest: 'none', - }], pending) - expect(commitPendingInstructionContexts(agent, [createUserMessage({ - content: [], source: { kind: 'plugin', plugin: 'workspace-context' }, - })], pending)).toEqual([]) - // A workspace-instructions source whose change list filters to nothing - // must not mint per-session pending state. - expect(commitPendingInstructionContexts(agent, [createUserMessage({ - content: [], - source: { kind: 'workspace-instructions', changes: [] }, - })], pending)).toEqual([]) - expect(pending.has(agent.session)).toBe(false) + ctx.emit('tools/result', first, acceptedResult) + ctx.emit('tools/result', second, acceptedResult) + await syncWorkspaceContext(ctx, agent) - const committed = commitPendingInstructionContexts(agent, [ - workspaceChangeContext('first', 'one'), - workspaceChangeContext('second', 'two'), - ], pending) - const [first, second] = committed - expect(first).toBeDefined() - expect(second).toBeDefined() - - const [newer] = commitPendingInstructionContexts(agent, [workspaceChangeContext('first', 'newer')], pending) - rollbackPendingInstructionChanges(agent, [first!], pending) - rollbackPendingInstructionChanges(agent, [{ - action: 'set', scope: 'unknown', path: 'unknown/AGENTS.md', digest: 'unknown', - }], pending) - rollbackPendingInstructionChanges(agent, [second!], pending) - expect(pending.get(agent.session)?.get('first')?.change).toEqual(newer) - - rollbackPendingInstructionChanges(agent, [newer!], pending) - expect(pending.has(agent.session)).toBe(false) + await vi.waitFor(() => { + expect(agent.inbox.nextStep).toHaveLength(1) + const text = blocksText(agent.inbox.nextStep[0]?.content) + expect(text).toContain('scope A') + expect(text).toContain('scope B') + }) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } }) + + it('merges a recovered pending context with a fresh touched scope', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'a/AGENTS.md'), 'recovered scope A') + await write(join(root, 'a/file.txt'), 'a') + await write(join(root, 'b/AGENTS.md'), 'fresh scope B') + await write(join(root, 'b/file.txt'), 'b') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, + }) + await syncWorkspaceContext(ctx, original) + const resumed = stubAgent(root, [...original.session.events]) + + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('recover-pending-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent: resumed, + }) + await syncWorkspaceContext(ctx, resumed) + + await vi.waitFor(() => { + expect(resumed.inbox.nextStep).toHaveLength(1) + const text = blocksText(resumed.inbox.nextStep[0]?.content) + expect(text).toContain('recovered scope A') + expect(text).toContain('fresh scope B') + }) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('enters an offline correction immediately after its claimed stale context', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'old claimed rule') + await write(join(root, 'pkg/file.txt'), 'file') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(join(root, 'pkg')) + await syncedWorkspaceContext(ctx, agent) + const claimed = agent.inbox.claim('next-step', 1) + await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail') + const downstream = { kind: 'enter' as const, messages: claimed } + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', claimed, + { turn: 1, step: 1, signal: testToolSignal }, + async () => downstream, + ) + + if (decision.kind !== 'enter') throw new Error('offline correction was rejected') + expect(decision.messages).toHaveLength(2) + expect(decision.messages[0]).toBe(claimed[0]) + expect(blocksText(decision.messages[0]?.content)).toContain('old claimed rule') + expect(blocksText(decision.messages[1]?.content)).toContain('new claimed rule with more detail') + expect(agent.inbox.nextStep).toHaveLength(0) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + }) describe('workspace context plugin export shape', () => { diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index 29f9e303de..2eacddb52c 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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/cordis/README.md -README.md: 485a6ce7858a77507c07b76138127faa411b354b -README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066 +README.md: e8b0790f911d1acc1216d412191ca967e0e1ac27 +README.zh.md: c452f9a7ef69e7f5af8900153271d500f19f9e47 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index 485a6ce785..e8b0790f91 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -6,5 +6,5 @@ Plugins that integrate Harness-owned formats with the Cordis runtime: the self-r | Package | Role | ctx key | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | -| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin | +| [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | Repository skill and MCP composition | registers a Loader builtin | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 38bfcd9fcb..c452f9a7ef 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -1,10 +1,10 @@ -# packages/cordis:Cordis 运行时集成 +# packages/cordis — Cordis 运行时集成 [English](README.md) | 中文 -这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。 +把 Harness 所有的格式与 Cordis 运行时集成的插件:自指的面向模型工具集,以及受限的 repository Plugin 运行时。 -| 包(package) | 角色 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | -| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin | +| [`tool-cordis/`](tool-cordis/README.md) | 面向模型的运行时检查和临时插件工具 | 注册到 `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | repository skill 与 MCP 组合 | 注册一个 Loader builtin | diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 8cd641781f..ea7a1305b3 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 -README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 +README.md: 2555876734ddcab7b0bc0f25780010bef0c97a01 +README.zh.md: 41341f7d7ed92ac071dde274a0328784bc2888d4 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 0ba1ce86d9..2555876734 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: ## Standalone app configuration -The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`): +The shipped `dsh` base used by raw-config, Web, and headless modes contains an empty `repository-plugins` row. A Web or headless user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`); a raw-config deployment patches the same row in its explicit overlay: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plug Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. -The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). +Web watches `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless reads the file only at startup, and raw-config mode reads only its explicit overlay. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). ## Preparation diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 2d9544166e..41341f7d7e 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 +这是 DeepSeek Harness 的受限 repository 插件格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill(技能)根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository 插件格式 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 ## 创作格式 -在仓库的 `.dsh-plugin` 目录中放置一个普通 package: +在仓库的 `.dsh-plugin` 目录中放置一个普通包: ```json { @@ -26,11 +26,11 @@ } ``` -`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 +`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 ## 独立应用配置 -已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation: +随附 `dsh` 中供原始配置、Web 与无头模式使用的基础配置包含一个空 `repository-plugins` 配置项。Web 或无头用户可在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,以启用精确指定的 GitHub generation;原始配置部署则在显式 overlay 中 patch 同一配置项: ```yaml - id: repository-plugins @@ -41,33 +41,33 @@ - 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin' ``` -每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 +每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 -TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 +Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头模式只在启动时读取该文件,原始配置模式只读取其显式 overlay。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 `dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 -外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。 +外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。 ## 运行时组合 -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。包装模块 dispose(资源释放)时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 ## 通用 MCP 格式 -`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。 +`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。 ## 导出形状 -Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 +Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 ## 模型体验 -### Repository skills +### Repository skill #### 模型看到什么 @@ -75,11 +75,11 @@ Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量 #### Token 影响 -有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。 +有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基址指引加入保留的工具历史。 #### KV Cache 影响 -稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。 +稳定的已准备插件集合保持前缀稳定。添加、移除或替换 repository 插件可能使消费方追加替换目录,并影响后续请求前缀。 ### Repository MCP 工具 @@ -89,14 +89,14 @@ Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量 #### Token 影响 -取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。 +取决于连接成功和远端工具列表;schema 会在当前工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩(compaction)。 #### KV Cache 影响 -稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 +稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 -- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 +- **仅支持 skill 与 MCP**:commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 - **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 - **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json index 07bfa924c2..87e88122f6 100644 --- a/packages/cordis/repository-plugin/package.json +++ b/packages/cordis/repository-plugin/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index ed9d80eea5..0a55bfc7f6 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md README.md: eda135d93e2912bbb4e111af40d176409b383b5b -README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc +README.zh.md: 773d4100f1be6c54f491838b65205b85ec61cdbc diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 6eef100861..773d4100f1 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 功能 @@ -14,7 +14,7 @@ 规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。 -临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包(package)、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。 +临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。 ## 信任立场 @@ -66,7 +66,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### cordis_mount 后的后续请求 diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index 7e7b75cab3..0b1c78cf8f 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f9af11105..d41da6ac9d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -146,6 +146,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'approval', summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', methods: [ + { + signature: 'setPolicy(agent: Agent, policy: ApprovalPolicy): void', + jsDoc: '/**\n * Switch one live agent\'s policy and queue the transition for its next model\n * step. Session initialization uses {@link setApprovalPolicy} directly\n * because there is no previously visible policy to change.\n * @param agent - the live agent whose policy is changing.\n * @param policy - the new effective policy.\n */', + }, { signature: 'async request(req: ApprovalRequest): Promise<ApprovalOutcome>', jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */', @@ -184,7 +188,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'collect(execution: ToolExecution): DshEnvironment', - jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', + jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one shell tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', }, { signature: 'list(): BashEnvVariableInfo[]', @@ -260,7 +264,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>', - jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations reserve idle turn admission synchronously before any\n * asynchronous work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and admission release. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - command-owned cancellation forwarded to summarization.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, changed-span,\n * summarization/shrink, commit-stage, or persistence failures, and the exact\n * abort reason when cancelled. Failed attempts remain visible in the log.\n */', + jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>', @@ -388,6 +392,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'register(route: WebRoute): () => void', jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */', }, + { + signature: 'registerUpgrade(route: WebUpgradeRoute): () => void', + jsDoc: '/**\n * Register an exact-path HTTP upgrade route. Duplicate paths throw because\n * one socket can have only one protocol owner.\n * @param route - pathname and handler owning negotiation plus socket use.\n * @returns the disposer removing the route.\n */', + }, { signature: 'tapIndex(transform: (html: string) => string): () => void', jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */', @@ -446,7 +454,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>', - jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Replay state is\n * retained only when the same adapter instance owns its historical provider\n * and the target provider. Final adapter selection remains fixed through\n * asynchronous exact-model resolution and dispatch. Adapter selection,\n * dispatch, and iteration failures become terminal `error` or `aborted`\n * finish chunks; middleware, nested-call, cleanup, and consumer failures\n * remain thrown.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', }, ], }, @@ -573,16 +581,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Durably persist a batch of events. Honors the append-only and contiguous-\n * seq contracts: the first event\'s `seq` MUST equal the stored next-seq\n * (after `load` has durably closed any interrupted turn). Rejects non-JSON-\n * serializable `event.data` with an error naming the offending event type.\n * @param id - the session the batch belongs to.\n * @param events - the contiguous batch to persist, in seq order.\n */', }, { - signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen. Coordinator-backed implementations upgrade supported pre-identity\n * message events before validation; other malformed messages reject before\n * any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + signature: 'async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation>', + jsDoc: '/**\n * Prepare the exact unpublished Session used by resume. Implementations may\n * reuse object graphs retained by an earlier {@link inspect} after confirming\n * their durable revision is still current; disposal releases an unpublished\n * reservation. Revision retries require the durable log to remain unchanged\n * for one read/check round trip; continuous external writers may delay completion.\n * @param id - persisted session to prepare.\n * @param signal - optional cancellation for preparation work.\n * @returns one owned unpublished Session preparation.\n */', }, { - signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + signature: 'abstract load(id: SessionId): Promise<SessionInspection>', + jsDoc: '/**\n * Load an immutable balanced logical view and commit any required cold\n * recovery. A complete interrupted final turn is preserved and durably\n * closed with missing tool errors plus any open step and turn boundaries;\n * only a torn final record is discarded. Unknown versions and corruption in\n * the committed prefix reject. Implementations MUST NOT crash-repair an\n * identity still bound to a live Session: a balanced live log may return as a\n * durable snapshot, while an open live turn rejects. Returned values may be\n * shared with immutable live or prepared state and must not be mutated.\n * Revision-based implementations may wait for one stable read/check round trip.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + }, + { + signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>', + jsDoc: '/**\n * Inspect an immutable logical session without committing recovery or\n * publishing it. A cold complete interrupted turn receives synthetic closers\n * in memory and a torn physical tail remains untouched. An already-live\n * Session instead yields its current immutable snapshot, which may contain an\n * open turn and its `session/end-seed` boundary. Coordinator-backed\n * implementations retain the exact cold unpublished Session for bounded\n * reuse by a later {@link prepare}. A stale ready source is reloaded; a source\n * already committing or reserved for resume remains exclusive, and inspection\n * may borrow its immutable view. Callers borrow only the immutable header and\n * log. Continuous external writers may delay revision convergence.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the validated header and current logical event log.\n */', }, { signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', + jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Unlike\n * {@link inspect}, it is a detached physical suffix read: no preparation\n * cache, torn-tail truncation, synthetic closers, or coordinator-state\n * publication. Only events from the valid contiguous stored prefix are\n * returned, so a torn fragment never reaches the caller. `fromSeq` at or\n * beyond the stored prefix returns an empty event list (never an error).\n * Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', }, { signature: 'abstract list(signal?: AbortSignal): Promise<SessionHeader[]>', @@ -728,11 +740,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', - jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', + jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', }, { - signature: 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - jsDoc: '/**\n * Build a session WITHOUT entering it into the store — validate the id/cwd and\n * construct the {@link Session} (with its immutable {@link SessionHeader}).\n * Pairs with {@link enter} + {@link announce}: a caller that owns a composite\n * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE\n * effect so a fiber unload tears the session + agent down as a single ORDERED\n * chain rather than as racing sibling effects — which would remove the publication hooks\n * before the loop\'s closing `session/flush`, dropping the closing events.\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the constructed session, NOT yet in the store.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path.\n */', + signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session', + jsDoc: '/**\n * Build a session WITHOUT entering it into the store — validate the id/cwd and\n * construct the {@link Session} (with its immutable {@link SessionHeader}).\n * Pairs with {@link enter} + {@link announce}: a caller that owns a composite\n * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE\n * effect so a fiber unload tears the session + agent down as a single ORDERED\n * chain rather than as racing sibling effects — which would remove the publication hooks\n * before the driver\'s closing events commit, dropping them.\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header. With\n * `seedSource: \'persistence\'`, metadata and events must be fresh detached\n * graphs whose ownership transfers to this call: they are validated and\n * frozen in place through {@link Session.fromRestore}, so the caller must\n * retain no mutable aliases.\n * @returns the constructed session, NOT yet in the store.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path.\n */', }, { signature: 'enter(session: Session): () => void', @@ -744,7 +756,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async flush(session: Session): Promise<boolean>', - jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', + jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the checkpoint policy\'s per-request\n * barrier, goal-session\'s idle checkpoint, teardown drains, and consumers\n * that flush themselves before reading storage) must come through here\n * rather than dispatch a raw `ctx.parallel(\'session/flush\', …)` — one owner,\n * one spelling, and the scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', }, { signature: 'get(id: SessionId): Session | undefined', @@ -786,6 +798,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'settings', summary: 'Abstract settings service.', methods: [ + { + signature: 'prepareDocument(): Promise<string | undefined>', + jsDoc: '/**\n * Prepare the provider\'s user-editable document for a native editor. File\n * providers may materialize an absent document before returning its path;\n * non-file providers return undefined.\n * @returns the absolute local document path, or undefined for non-file storage.\n */', + }, { signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>', jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */', @@ -946,7 +962,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'context(context: PromptContext): () => void', - jsDoc: '/**\n * Register ordered cache-safe dynamic context in the calling context\'s scope.\n * A scoped context shadows a global context with the same name; duplicates\n * within one layer and non-finite orders throw. Registration and disposal\n * emit `system-prompt/change`.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */', + jsDoc: '/**\n * Register ordered dynamic context in the calling context\'s scope. Scoped\n * entries shadow global entries with the same name.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */', }, { signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', @@ -1028,7 +1044,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'estimateMessage(message: Message): number', - jsDoc: '/**\n * Heuristically price one model-visible message.\n * @param message - message to price without mutation.\n * @returns content and role-framing tokens under the fixed service heuristic.\n */', + jsDoc: '/**\n * Heuristically price one model-visible message (instance face of the pure\n * `estimateMessage` export from `estimate.ts`).\n * @param message - message to price without mutation.\n * @returns content and role-framing tokens under the fixed service heuristic.\n */', }, ], }, @@ -1046,7 +1062,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'pruneSession(session: Session): PruneResult', - jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', + jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * points at the shadowed node for durable provenance and replay, and is\n * immediately preceded by a `compact/prune` shadow-price event pricing the\n * shadowed node through the injected token meter, so pure consumers can\n * subtract it without per-node state.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', }, ], }, @@ -1205,13 +1221,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, - { - name: 'agent/cancel-requested', - mode: 'emit', - signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void', - jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - the explicit typed cancellation cause.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.', - }, { name: 'agent/created', mode: 'emit', @@ -1230,43 +1239,36 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/error', mode: 'emit', signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void', - jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, { - name: 'agent/inbox/dequeue', + name: 'agent/inbox/claimed', mode: 'emit', - signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param item - the exact claimed occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', + signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void', + jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message left the inbox inside its open turn.', }, { - name: 'agent/inbox/discard', + name: 'agent/inbox/discarded', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, items: InboxItem[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param items - the discarded occurrences in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', + signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', + jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message was discarded from the live inbox.', }, { - name: 'agent/inbox/enqueue', + name: 'agent/inbox/inserted', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void', - jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param item - accepted occurrence, message, and resolved placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'An item entered the queued or steering inbox.', + signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', + jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message entered the live inbox.', }, { - name: 'agent/inbox/update', - mode: 'emit', - signature: '\'agent/inbox/update\'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void', - jsDoc: '/**\n * A still-pending queued item changed content. The item id, placement, and\n * position remain stable while the event carries the replacement message.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'A still-pending queued item changed content.', - }, - { - name: 'agent/prompt-submit', + name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose turn claimed the message.\n * @param message - the frozen claimed message, including identity and source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn.', + signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>', + jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Reject a proposed step or replace the messages that enter it.', }, { name: 'agent/request', @@ -1278,9 +1280,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>', - jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener returns `{ kind: \'retry\' }`\n * without calling `next()` when it owns the error, or calls `next()` to\n * delegate. The default `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another\n * retry turn in this consecutive sequence.\n * @param retryPolicy - immutable policy of the adapter registration that served\n * the failed request, or `undefined` if no final adapter served it.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Handle a model-request failure after its failed step has closed but before the failed turn closes.', + signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>', + jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', }, { name: 'agent/session-start', @@ -1289,32 +1291,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The session lifecycle began, once before the first turn.', }, - { - name: 'agent/settled', - mode: 'emit', - signature: '\'agent/settled\'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void', - jsDoc: '/**\n * One drain chain reached its terminal turn: that turn\'s `turn/end` is\n * already committed. Automatically recovered failed turns do not emit this\n * notification, and neither does a run that aborts or fails before its\n * `turn/start` commits — there is no durable turn to settle against.\n * `reason` says why; model-request recovery is exhausted when an error\n * reaches it.\n * @param agent - the agent whose turn closed.\n * @param turn - the terminal turn number.\n * @param reason - why the terminal turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'One drain chain reached its terminal turn: that turn\'s `turn/end` is already committed.', - }, { name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). `send()` does not enter\n * `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, - { - name: 'agent/step', - mode: 'serial', - signature: '\'agent/step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void', - jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" extension point: inject context, steer, or edit the session log\n * here — the request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).', - }, { name: 'agent/turn-stopping', mode: 'serial', signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void', - jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { @@ -1370,7 +1358,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'goal/changed', mode: 'emit', signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void', - jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', summary: 'Goal mutation accepted by one live agent.', }, { @@ -1579,11 +1567,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', - declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', + declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n} | {\n readonly kind: \'hook\';\n readonly reason: string;\n} | {\n readonly kind: \'disposed\';\n};', }, { name: 'AgentFactory', @@ -1715,7 +1703,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CancelOptions', - declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}', + declaration: 'export interface CancelOptions {\n keepInbox?: boolean | undefined;\n}', }, { name: 'CodeBindingErrorClass', @@ -1791,7 +1779,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConfinedArgv', - declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}', + declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', }, { name: 'ConfinedSandboxMode', @@ -1805,6 +1793,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextFormed', + declaration: 'export type ContextFormed = {\n readonly form?: never;\n} | {\n readonly form: \'instructions\';\n} | {\n readonly form: \'catalog\';\n} | {\n readonly form: \'snapshot\';\n readonly sections: readonly ContextSnapshotSection[];\n} | {\n readonly form: \'notice\';\n readonly summary: string;\n} | {\n readonly form: \'relay\';\n} | {\n readonly form: \'recall\';\n};', + }, + { + name: 'ContextSnapshotSection', + declaration: 'export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -2022,16 +2018,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, { - name: 'InboxAction', - declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', + name: 'Inbox', + declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', }, { - name: 'InboxActionResult', - declaration: 'export type InboxActionResult = \'applied\' | \'not-found\' | \'steer-unavailable\';', + name: 'InboxNotifications', + declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n}', }, { - name: 'InboxItemId', - declaration: 'export type InboxItemId = Branded<\'InboxItemId\'>;', + name: 'InboxTarget', + declaration: 'export type InboxTarget = \'next-turn\' | \'next-step\';', }, { name: 'InvariantFailure', @@ -2115,7 +2111,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ManualCompactAgentContext', - declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}', + declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n}', }, { name: 'Message', @@ -2131,7 +2127,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'MessageSourceMap', - declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', + declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, { name: 'ModelMessageSource', @@ -2151,12 +2147,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}', }, { name: 'PreparedReferencedMessage', declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: UserMessage;\n}', }, + { + name: 'PrepareSessionOptions', + declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n readonly seedSource?: undefined;\n}) | RestoredSessionOptions;', + }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -2333,10 +2333,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedSubagentStartRequest', declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly descriptor: SubagentDescriptorData;\n}', }, + { + name: 'RestoredSessionOptions', + declaration: 'export interface RestoredSessionOptions {\n readonly seed: SessionEvent[];\n readonly meta: SessionHeader;\n readonly seedSource: \'persistence\';\n}', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, + { + name: 'RunnerFailureRule', + declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -2385,17 +2393,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, - { - name: 'SendOptions', - declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', - }, - { - name: 'SendTarget', - declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', - }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', @@ -2407,7 +2407,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}', }, { name: 'SessionEventMetadataFilter', @@ -2477,6 +2477,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionInspection', + declaration: 'export interface SessionInspection {\n readonly meta: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', + }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -2501,6 +2505,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionPersistenceSnapshot', declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}', }, + { + name: 'SessionPreparation', + declaration: 'export class SessionPreparation implements Disposable {\n readonly session: Session;\n static create(session: Session, options?: SessionPreparationOptions): SessionPreparation;\n [Symbol.dispose](): void;\n}', + }, + { + name: 'SessionPreparationOptions', + declaration: 'export interface SessionPreparationOptions {\n readonly release?: () => void;\n}', + }, { name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', @@ -2693,14 +2705,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, - { - name: 'SteeringOutcome', - declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};', - }, - { - name: 'SteeringReceipt', - declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise<SteeringOutcome>;\n}', - }, { name: 'StorageForms', declaration: 'export interface StorageForms {\n}', @@ -2803,7 +2807,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SurfaceEventType', - declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', + declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\';', }, { name: 'SurfaceIntent', @@ -2993,21 +2997,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}', }, + { + name: 'TurnEndCancelCause', + declaration: 'export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: \'legacy\';\n};', + }, { name: 'TurnEndReason', declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];', }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', - }, - { - name: 'TurnTrigger', - declaration: 'export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];', - }, - { - name: 'TurnTriggerMap', - declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'TypertContribution', @@ -3129,6 +3129,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSource', declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', }, + { + name: 'WebUpgradeRoute', + declaration: 'export interface WebUpgradeRoute {\n path: string;\n handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>;\n}', + }, { name: 'WorkflowMeta', declaration: 'export interface WorkflowMeta {\n name: string;\n description: string;\n whenToUse?: string;\n phases?: WorkflowPhase[];\n}', diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index 58d1534fd0..d43caa011e 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/README.md -README.md: 63ca0f7711c2c9deb193d5a9a574ff909a04602e -README.zh.md: a217868f6c25ddce690ca32fc09b1d68b8ab48d2 +README.md: d51ef73f7f545920f8cd527b2b0d597305ae86d0 +README.zh.md: 729f41fafb6b9585a66e58fe8d6ac63c1f235acc diff --git a/packages/core/README.md b/packages/core/README.md index 63ca0f7711..d51ef73f7f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,15 +6,13 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | Package | Role | ctx key | |---|---|---| -| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) | -| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | -| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | -| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | -| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` | +| [`scope/`](scope/README.md) | Scoped-context registration primitive | library — no ctx key | +| [`session/`](session/README.md) | Event-sourced session log and in-memory store | `ctx.sessions` | +| [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | +| [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | +| [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | +| [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. +`scope` supplies the shared scoping primitive. `agent` owns the public seam, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. -`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable. - -The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + fallback session titles + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door. +Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/README.zh.md b/packages/core/README.zh.md index a217868f6c..729f41fafb 100644 --- a/packages/core/README.zh.md +++ b/packages/core/README.zh.md @@ -1,20 +1,18 @@ -# core/:产品 API 主干 +# core/ — 产品 API 主干 [English](README.md) | 中文 -会话日志、系统提示词组装、工具注册表、agent(智能体)词汇,以及构成 harness 默认控制主干的具体循环。这些是 **产品** 包(package),插件和消费方以其稳定接口为基础构建。 +构成 harness 默认控制主干的会话日志、系统提示词组装、工具注册表、agent(智能体)词汇和具体循环。这些是**产品**包,即插件和消费方构建所依赖的稳定 surface。 -| 包 | 角色 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| `scope/` | 带作用域的上下文注册原语(作用域标签、按作用域筛选的分发) | (库,没有 ctx 键) | -| `session/` | 事件溯源会话日志与内存存储 | `ctx.sessions` | -| `system-prompt/` | 提示词段与工具 schema 组装注册表 | `ctx.systemPrompt` | -| `tools/` | 带作用域的工具注册表,以及前置策略、守卫、环绕分发、后置策略与最终结果观测 | `ctx.tools` | -| `agent/` | Agent 接口、实时注册表、进程本地发起方作用域、`agent/*` 事件词汇 | `ctx.agents` | -| `agent-loop/` | 实现公开 `Agent` 契约并拥有循环驱动器的具体插件 | `ctx.agentLoop` | +| [`scope/`](scope/README.md) | 作用域上下文注册原语 | 库,不使用 ctx key | +| [`session/`](session/README.md) | 事件溯源会话日志和内存存储 | `ctx.sessions` | +| [`system-prompt/`](system-prompt/README.md) | 提示词和工具 schema 组装注册表 | `ctx.systemPrompt` | +| [`tools/`](tools/README.md) | 作用域工具注册表和执行流水线 | `ctx.tools` | +| [`agent/`](agent/README.md) | Agent 接口、注册表和事件词汇 | `ctx.agents` | +| [`agent-loop/`](agent-loop/README.md) | 默认具体 agent 驱动器 | `ctx.agentLoop` | -`scope/` 是此处唯一的非服务包:它是不含依赖的库(`createScope`/`scopeOf`/`scopeTarget`),注册表和循环基于它实现按 agent 分域。它在模块图中位于 `session/` 和 `system-prompt/` 之下,正是为了让二者可以消费它而不形成环。 +`scope` 提供共享作用域原语。`agent` 负责公开 seam,`agent-loop` 是其默认实现;扩展插件依赖该 seam,从而保持驱动器可替换。 -`agent-loop` 是 `agent` seam 的唯一具体实现,位于此处是因为它就是 harness 的默认产品循环。它在 `ctx.agents.withInitiator()` 中运行每个驱动器。扩展插件依赖 `agent`,即使需要发起调用的 Agent 也是如此;它们绝不直接依赖 `agent-loop`,因此循环保持可替换。 - -将这条主干接成可运行 agent 的默认组合位于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md):一个 bundle(组合包)插件,加载控制主干及所选默认能力(`timer` + `llm` + 会话 + 后备会话标题 + 系统提示词 + 工具 + agent + 不变式 + 本地 [skill(技能)系列](../skill/README.md) + `tool-bash` + 工作区上下文 + `agent-loop`),并将 `agent-loop` 的 `agents` 列表作为自身配置转发。它位于 `examples/`,即开箱可运行的演示/参考组合包,而不是 `core/`:`core/` 交付可替换的主干组件,演示组合包则选定其中一种具体组合并添加一个对外交互入口。 +可运行组合属于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md);该分组只负责可替换的主干组件。 diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 81f5271097..b25a0f18f1 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 2ce85071c4b7408adb4ee05291c499ec642be114 -README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced +README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f +README.zh.md: 5828da301b35c95719286fb942ac239769539b67 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2ce85071c4..ec1948506b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. @@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -53,13 +53,11 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. +The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver. -`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it. - -Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and strict steer transfers the immutable message into an open next-step window as a new steering occurrence. A closed window returns `steer-unavailable` without mutation; pending steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection. ### Loop lifecycle (`agent.ts`) @@ -69,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. @@ -77,7 +75,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) -- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error` +- Compaction: pressure on `agent/pre-step`; canonical overflow repair on `agent/request-error` - Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index bc78c02fc0..5828da301b 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -唯一的实体 agent(智能体)插件与循环驱动器。其包(package)内部实现满足 `Agent` 接口,并驱动会话/轮次/步骤生命周期。 +唯一的具体 agent(智能体)插件与循环驱动器。其包内部实现满足 `Agent` 接口,并驱动会话/轮次/步骤生命周期。 -这是 harness 中唯一包含实体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展 seam 的插件:新行为应放入插件,而不是这里。 +这是 harness 中唯一包含具体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展 seam 的插件:新行为应放入插件,而不是这里。 ## 服务:`AgentLoop`(ctx 键:`agentLoop`) ### 公开 API -创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 @@ -20,8 +20,8 @@ `AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 @@ -51,25 +51,23 @@ interface Config { 通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 -### 包内部实体驱动器 +### 包内部具体驱动器 -实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 +实体 `ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会先打开持久轮次,再原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除,并关闭不含步骤的轮次;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。 -`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。 - -每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值。 ### 循环生命周期(`agent.ts`) -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递实体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递具体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的分片溯源(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 -插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 @@ -77,7 +75,7 @@ interface Config { 超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件: - 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md) -- 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复 +- 压缩(compaction):在 `agent/pre-step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复 - 模型请求恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作 - 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测 - subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。 @@ -126,7 +124,7 @@ interface Config { #### KV Cache 影响 -仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 8e9a2b93bb..68c30d9e55 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7954d31cf3..2b97dad3a4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -1,124 +1,53 @@ /** - * Concrete Agent loop over two pending-input lists: queued prompts each open a - * turn that logs its admitted input after `turn/start` commits, while steering - * and injected context enter through the outbox at step boundaries. Every - * request is derived from the session log. An idle turn-admission reservation - * can withhold the driver from the queue without touching its contents. - * + * Default Agent driver over queued turns and step-boundary input. Every request + * is derived from the session log. * @module dsh-agent-loop/agent */ -import type { Context } from 'cordis' -import { randomUUID } from 'node:crypto' -import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' -import { createScope } from '@deepseek-ai/dsh-scope' -import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent, - CancelOptions, - AgentInterruptReason, - InboxAction, - InboxActionResult, - InboxItem, - InboxItemId as InboxItemIdType, - InboxPlacement, + AgentCancelCause, AgentOptions, AgentStatus, - SettleReason, - PromptDecision, - RequestError, + CancelOptions, + InboxTarget, RequestErrorAction, - SendOptions, - SteeringOutcome, - SteeringReceipt, } from '@deepseek-ai/dsh-agent' +import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, LlmError, - assertNever, createAssistantMessage, - createUserMessage, deepFreeze, errorChain, - freezeMessage, - isHarnessError, - llmFailureOf, - llmRetryPolicyOf, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { Scope } from '@deepseek-ai/dsh-scope' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' -import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' +import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import type { Context } from 'cordis' +import { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' -/** One completed step or a final-adapter failure eligible for recovery. */ -type StepOutcome = - | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } - | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } - -/** Internal one-shot controller paired with a public steering receipt. */ -interface SteeringDelivery { - readonly receipt: SteeringReceipt - settle(outcome: SteeringOutcome): void -} - -/** Create one idempotent steering-admission controller. */ -function createSteeringDelivery(): SteeringDelivery { - const { promise, resolve } = Promise.withResolvers<SteeringOutcome>() - let settled = false - return { - receipt: { outcome: promise }, - settle(outcome): void { - /* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */ - if (settled) return - settled = true - resolve(outcome) - }, +type Phase = + | { kind: 'idle'; lastTurn: number } + | { + kind: 'maintenance' + abort: AbortController + lastTurn: number + wakeRequested: boolean } -} + | { kind: 'running'; abort: AbortController; turn: number; step: number } -const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt' -/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */ -const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' +type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }> -/** Whether one user message is owned by runtime-context materialization. */ -function isRuntimeContextMessage(message: UserMessage): boolean { - return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE -} - -/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */ -function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } { - const events = session.events - const nodes = session.surface.nodes - for (let index = nodes.length - 1; index >= 0; index -= 1) { - const event = events[nodes[index] as number] - if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue - const [block] = event.data.content - return { - found: true, - text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined, - } - } - return { found: false, text: undefined } -} - -/** Append a full current snapshot only when it changed or compaction removed it. */ -function materializeRuntimeContext(session: Session, current: string): void { - const previous = retainedRuntimeContext(session) - if (!previous.found && current.length === 0) { - const compactedPriorSnapshot = session.surface.replaceGeneration > 0 - && session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data)) - if (!compactedPriorSnapshot) return - } - const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current - if (previous.text === snapshot) return - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: snapshot }], - source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE }, - }), { surfaceOp: 'append' }) -} +type PreparedStep = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly } /** Remove adapter-derived values before plugins propose the next request config. */ function requestProposal(header: EpochHeader): LlmCallConfig { @@ -129,48 +58,19 @@ function requestProposal(header: EpochHeader): LlmCallConfig { return proposal } -/** - * The concrete {@link Agent}: each `run()` owns one turn and repeats model - * steps while tools or steering require another request. - */ +/** Drives one session through turn and step boundaries. */ export class ReactLoopAgent implements Agent { - /** Prompts awaiting individual turns. */ - private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = [] - /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = [] - /** Steering already committed to the log but not yet captured by a request. */ - private pendingAdmissions: SteeringDelivery[] = [] - /** Whether the active cancellation preserves already committed pending delivery. */ - private preservePendingAdmissionsOnAbort = false + readonly inbox: Inbox + private phase: Phase + private activityDone: Promise<void> = Promise.resolve() - /** Whether observers see a running interval; consecutive turns share it. */ - private busy = false - /** Whether an idle waking send has deferred driver admission. */ - private wakeScheduled = false - /** - * The live idle turn-admission reservation, holding the driver out of the - * queue until its owner releases. It settles idle waiters instead of - * {@link done} so lifecycle teardown never awaits the reserving operation. - */ - private admission: { readonly settled: Promise<void>; readonly settle: () => void } | undefined - /** Whether next-step input belongs to the current admission or open turn. */ - acceptsNextStep = false - /** Abort owner for the current admission or turn. */ - private abort: AbortController | undefined - /** Resolves when the current admission and turn exit. */ - done: Promise<void> = Promise.resolve() - /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ + /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */ readonly scope: Scope - /** The agent's scoped composition context ({@link Agent.ctx}). */ readonly ctx: Context - /** Last turn number opened by this loop or present in its seeded log. */ - private lastTurn: number - /** Whether the session log is owed a matching turn end event. */ - private turnOpen = false - private stepOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false + private readonly runtimeContext: RuntimeContextProjection constructor( private loopCtx: Context, @@ -178,609 +78,295 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + this.inbox = new Inbox(session, { + inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, + discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, + claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) }, + }) + const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) + this.runtimeContext = new RuntimeContextProjection(this.ctx, session) } - /** Last activity state published to observers. */ get status(): AgentStatus { - return this.busy ? 'running' : 'idle' + return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } - /** Accept and route one unified send item. */ - send( - message: UserMessage, - options: SendOptions, - ): void { - this.route(message, options) + /** Commit a phase and publish its externally visible status transition. */ + private setPhase(next: Phase): void { + const previousStatus = this.status + this.phase = next + const status = this.status + if (status !== previousStatus) { + emitAgentEvent(this.loopCtx, this, 'agent/status', status) + } } - /** Route one accepted message, optionally tracking steering admission. */ - private route( - message: UserMessage, - options: SendOptions, - delivery?: SteeringDelivery, - ): void { - const { target, wakeup } = options - if (target === 'next-step' && !wakeup) { - if (this.acceptsNextStep) { - this.outbox.push({ message, steering: false }) - return + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { + // Waking input cannot join an aborted activity, so it starts the next turn. + const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted + const resolvedTarget = wakingAfterAbort ? 'next-turn' : target + this.inbox.splice(resolvedTarget, Infinity, 0, [message]) + if (wakeup) this.wakeDriver() + } + + followup(input: UserMessage): void { + this.send(input, 'next-turn', true) + } + + steer(input: UserMessage): void { + this.send(input, 'next-step', true) + } + + inject(input: UserMessage): void { + this.send(input, 'next-step', false) + } + + cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { + if (!options.keepInbox) { + this.inbox.clear() + if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + } + if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) + } + + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> { + if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`) + const done = Promise.withResolvers<void>() + const maintenance: Phase = { + kind: 'maintenance', + abort: new AbortController(), + lastTurn: this.phase.lastTurn, + wakeRequested: false, + } + this.setPhase(maintenance) + this.activityDone = done.promise + return (async () => { + try { + return await task(maintenance.abort.signal) + } finally { + this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) + if (maintenance.wakeRequested) this.wakeDriver() + done.resolve() } - this.session.append('user/message', message, { surfaceOp: 'append' }) + })() + } + + /** Start one driver, or remember its wake behind maintenance. */ + private wakeDriver(): void { + if (this.phase.kind === 'maintenance') { + if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true return } - - const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' - const item: InboxItem = Object.freeze({ - id: InboxItemId(randomUUID()), - message, - placement, - }) - if (placement === 'steering') { - this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } }) - } else { - this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } }) - } - // Preserve the routing decision for every send in this synchronous caller - // stack, while installing quiescence ownership before enqueue observers - // can cancel or dispose. - if (placement === 'queued' && wakeup) this.scheduleKick() - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) + if (this.phase.kind !== 'idle') return + const driver = Promise.withResolvers<void>() + this.activityDone = driver.promise + this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 }) + this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } - /** Apply one synchronous mutation to a still-pending queued occurrence. */ - updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult { - const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id) - if (queuedIndex === -1) return 'not-found' - - const pending = this.queued[queuedIndex] - /* v8 ignore next -- the index was resolved from this array without an async boundary. */ - if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`) - - /* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */ - switch (action.kind) { - case 'edit': { - const item: InboxItem = Object.freeze({ - ...pending.item, - message: freezeMessage({ ...pending.item.message, content: action.content }), - }) - this.queued[queuedIndex] = { ...pending, item } - emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item) - return 'applied' - } - case 'remove': { - this.queued.splice(queuedIndex, 1) - pending.delivery?.settle({ status: 'rejected' }) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) - return 'applied' - } - case 'steer': { - if (!this.acceptsNextStep) return 'steer-unavailable' - this.queued.splice(queuedIndex, 1) - const item: InboxItem = Object.freeze({ - id: InboxItemId(randomUUID()), - message: pending.item.message, - placement: 'steering', - }) - this.outbox.push({ - message: item.message, - steering: true, - item, - ...pending.delivery === undefined ? {} : { delivery: pending.delivery }, - }) - // Publish the replacement only after it is owned by the outbox. Its - // enqueue precedes the old occurrence's discard so reentrant - // cancellation can terminally account for both occurrences. - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) - return 'applied' - } - default: - /* v8 ignore next -- InboxAction is a closed discriminated union. */ - return assertNever(action) - } - } - - /** Queue one ordinary prompt turn and wake the driver. */ - followup(input: UserMessage): void { - this.send(input, { - target: 'next-turn', - wakeup: true, - }) - } - - /** Steer the open turn, falling back to a tracked waking prompt while idle. */ - steer(input: UserMessage): SteeringReceipt { - const delivery = createSteeringDelivery() - this.route(input, { - target: 'next-step', - wakeup: true, - }, delivery) - return delivery.receipt - } - - /** Append model-facing context without waking the driver. */ - inject(input: UserMessage): void { - this.send(input, { - target: 'next-step', - wakeup: false, - }) - } - - /** - * Hold the idle admission boundary so no queued prompt can open a turn until - * the returned release runs. Later sends keep their ordinary placement and - * `wakeup` facts; only the driver's claim waits. - * @returns the idempotent release, or `undefined` when the driver is active or already committed to waking work. - */ - reserveTurnAdmission(): (() => void) | undefined { - // `busy` covers every abort owner: kick() and run() mark the interval - // running before they install one. `wakeScheduled` is the same-tick state - // of an accepted waking prompt whose claim is still a pending microtask. - if (this.busy || this.wakeScheduled || this.admission !== undefined - || this.queued.some(item => item.wakeup)) return undefined - const pending = Promise.withResolvers<void>() - const reservation = { settled: pending.promise, settle: pending.resolve } - this.admission = reservation - return () => { - // Idempotent, and inert once a later reservation owns the boundary. - if (this.admission !== reservation) return - this.admission = undefined - // Re-arm the ordinary path first, so an idle waiter released below - // re-reads live admission activity instead of settled state. - if (this.queued.some(item => item.wakeup)) this.scheduleKick() - reservation.settle() - } - } - - /** - * Clear all pending work and abort the active turn; the first cause wins. - * The cause is signal payload for observers and the durable turn/end - * classification — it selects no machine behavior. Teardown is just - * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, - * all owned by the factory. - */ - cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { - // Effective only when it aborts the active turn or actually discards - // pending work: a keepInbox call with no active turn is a documented - // no-op, so it must not emit cancel-requested for consumers to misread. - const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0) - if (this.abort !== undefined || discards) { - // Observe-only: coordination consumers update their state before the - // inboxes clear; listener failures are contained by the dispatcher. - if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) - } - if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true - if (!options.keepInbox) { - const discarded = this.queued.map(item => item.item) - for (const item of this.queued) item.delivery?.settle({ status: 'rejected' }) - for (const item of this.outbox) { - if (item.steering && item.item !== undefined) { - item.delivery?.settle({ status: 'rejected' }) - discarded.push(item.item) - } - } - this.rejectPendingAdmissions() - // Clear before abort observers run: replacement work belongs to the next turn. - this.queued.length = 0 - this.outbox.length = 0 - if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) - } - const reason = Object.freeze({ kind: cause.kind }) - this.abort?.abort(reason) - } - - /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ async whenIdle(): Promise<void> { - while (true) { - // `done` is replaced per activity, so re-reading it follows chained turns. - // Every driver failure today is contained before it can reject `done`, - // but the waiter must not gamble quiescence on that: a future escape - // still counts as settled activity. - /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ - while (this.busy || this.wakeScheduled || this.abort !== undefined || this.runnableWakingQueued) { - await this.done.catch(() => undefined) - } - // A reservation is unfinished activity even with an empty queue, and a - // prompt it withholds is not quiescent — but `done` never owns it, so - // waiting on the queue alone would spin on an already-settled promise. - const reservation = this.admission - if (reservation === undefined) return - await reservation.settled - } + let activity: Promise<void> + do { + await (activity = this.activityDone) + } while (activity !== this.activityDone) } - /** Whether a queued waking prompt may claim the driver now. */ - private get runnableWakingQueued(): boolean { - return this.admission === undefined && this.queued.some(item => item.wakeup) + /** Report one failure at its live boundary, then preserve it for driver containment. */ + private throwError(error: unknown): never { + const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn + const step = this.phase.kind === 'running' ? this.phase.step : 0 + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + throw error } - /** Defer idle admission while keeping {@link done} as its quiescence owner. */ - private scheduleKick(): void { - // A held reservation keeps the item queued with no scheduled claim; its - // release re-arms this path for whatever is queued by then. - if (this.abort !== undefined || this.wakeScheduled || this.admission !== undefined) return - this.wakeScheduled = true - const pending = Promise.withResolvers<void>() - const scheduled = pending.promise - queueMicrotask(() => { - this.wakeScheduled = false - this.kick() - const activity = this.done - if (activity === scheduled) { - pending.resolve() - } else { - void activity.then( - () => { pending.resolve() }, - () => { pending.resolve() }, - ) - } - }) - this.done = scheduled - } - - /** Claim and admit the next queued prompt, then start its turn. */ - private kick(): void { - if (this.abort !== undefined || !this.runnableWakingQueued) return - // The some() guard above proves the queue is non-empty; the non-null - // assertion expresses that invariant. - // oxlint-disable-next-line typescript/no-non-null-assertion - const pending = this.queued.shift()! - const { item, delivery } = pending - const { message } = item - const inheritedOutboxLength = this.outbox.length - - const admission = new AbortController() - this.abort = admission - this.acceptsNextStep = true - // Claimed admission is part of the running interval: it is cancellable - // activity, so observers (and their cancel routing) must see it. - if (!this.busy) { - this.busy = true - emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') - } - // The admission body runs synchronously up to the prompt-submit - // waterfall's first await, so the waterfall snapshots its listeners - // before a disposal initiated by the running-status emit above can - // unregister a vetoing plugin. - this.done = this.loopCtx.agents.withInitiator(this, async () => { - const signal = admission.signal - const trigger: TurnTrigger = { kind: 'message', source: message.source } - // Admitted input stays on the stack until its turn/start commits: the - // turn owns it only once the turn exists in the log. - let admitted: UserMessage[] | undefined - try { - signal.throwIfAborted() - const decision = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/prompt-submit', this, message, signal, - () => Promise.resolve<PromptDecision>({ kind: 'allow' }), - ) - signal.throwIfAborted() - - if (decision.kind === 'allow') { - admitted = [decision.content === undefined - ? message - : freezeMessage({ ...message, content: decision.content })] - for (const context of decision.additionalContexts ?? []) { - admitted.push(freezeMessage(context)) - } - } - } catch (error: unknown) { - if (!signal.aborted) { - this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`) - } - } - - // cancel() aborts but never clears the slot, and kick()/run() - // all refuse to install a new owner while one exists, so the admission - // still owns the slot here and releasing it unconditionally is exact. - this.abort = undefined - if (admitted === undefined) { - delivery?.settle({ status: 'rejected' }) - this.acceptsNextStep = false - try { - this.flushRejectedAdmissionContexts() - } catch (error: unknown) { - // No turn exists for agent/error coordinates. Preserve the - // uncommitted suffix for a later boundary and report locally. - this.loopCtx.logger.warn( - `agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`, - ) - } - // A synchronously aborted admission would otherwise publish idle - // inside send()'s own synchronous extent, before any post-send - // subscriber could observe the transition. - await Promise.resolve() - this.continueOrIdle() - return - } - await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery) - }) - // Published only after the abort owner and pending done are installed: a - // dequeue listener that cancels or disposes must find live cancellation - // and quiescence ownership, not the previous activity's settled state. - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item) - } - - /** - * Run one turn and any request-error retry. `admitted` input enters the log - * only after `turn/start` commits; until then it has no owner state to unwind. - */ - private async run( - trigger: TurnTrigger, - admitted: UserMessage[] = [], - inheritedOutboxLength = 0, - priorFailures: readonly LlmFailure[] = Object.freeze([]), - promptDelivery?: SteeringDelivery, - ): Promise<void> { - // Both entries hold the invariant: kick() clears the admission slot before - // awaiting run(), and a retry is entered only after the prior run clears it. - /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */ - if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) - const controller = new AbortController() - this.abort = controller - this.preservePendingAdmissionsOnAbort = false - this.acceptsNextStep = true - const signal = controller.signal - const turn = this.lastTurn + 1 - let step = 0 - let opened = false - let reason: TurnEndReason = { kind: 'completed' } - let settleReason: SettleReason = { kind: 'completed' } - let requestFailureHistory = priorFailures - let retryFailures: readonly LlmFailure[] | undefined - const cancelRetry = (): void => { retryFailures = undefined } - signal.addEventListener('abort', cancelRetry, { once: true }) - + private async kick(): Promise<void> { try { - signal.throwIfAborted() - this.session.append('turn/start', { turn, trigger }) - // Committed: publish the turn to the machine's own bookkeeping and let - // the admitted input enter the log it now belongs to. - this.turnOpen = true - opened = true - this.lastTurn = turn - // Context or steering retained by an earlier rejected admission happened - // before this prompt and must occupy the same order in durable history. - this.drainOutbox(turn, inheritedOutboxLength) - if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery) - for (const input of admitted) { - this.session.append('user/message', input, { surfaceOp: 'append' }) - } - signal.throwIfAborted() - - steps: while (true) { - step += 1 - const outcome = await this.step(turn, step, signal) - switch (outcome.kind) { - case 'completed': - requestFailureHistory = Object.freeze([]) - if (outcome.maxTokens) reason = { kind: 'max-tokens' } - // A concluding tool result is terminal: reject steering that did - // not enter a request, while retaining same-boundary context in - // durable history before the turn closes. - if (outcome.concluded) { - this.discardOutboxSteering() - this.drainOutbox(turn) - break steps - } - /* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */ - if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue - break - case 'request-failed': { - // step() reports request failures only after step/start commits - // and before its own step/end, so the step is always open here. - this.stepOpen = false - this.session.append('step/end', { turn, step }) - if (!signal.aborted) { - try { - const action = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error, - outcome.failure, requestFailureHistory, outcome.retryPolicy, signal, - () => Promise.resolve<RequestErrorAction>(undefined), - ) - // oxlint-disable-next-line typescript/no-unnecessary-condition -- signal can abort while recovery is awaited. - if (action?.kind === 'retry' && !signal.aborted) { - retryFailures = Object.freeze([...requestFailureHistory, outcome.failure]) - } - } catch (recoveryError: unknown) { - this.loopCtx.logger.warn( - `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, - ) - } - } - const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure) - reason = settlement.reason - settleReason = settlement.settleReason - break steps - } - /* v8 ignore next 2 -- closed-union exhaustiveness guard */ - default: - assertNever(outcome) - } - await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) - signal.throwIfAborted() - this.drainOutboxContexts() - if (!this.outbox.some(item => item.steering)) { - break - } - } - } catch (caught: unknown) { - try { - if (this.stepOpen) { - this.stepOpen = false - this.session.append('step/end', { turn, step }) - } - } catch (closeError: unknown) { - // Contained like the finally's turn close: a persistently rejecting - // step boundary must not escape run(), or the post-finally tail would - // never publish the terminal status and observers would see a - // permanently running agent whose whenIdle() already resolved. - this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError) - } - ({ reason, settleReason } = this.settle(turn, step, caught, signal)) + while (await this.turn()) {} + } catch (_error) { + // Reported failures and cancellation are contained at the driver boundary. } finally { - // Every step-close happens before this point on both success and - // failure paths (step(), the request-failed branch, the catch), so the - // finally owes only the turn boundary. - this.acceptsNextStep = false - try { - if (this.turnOpen) { - // Re-entrant turn/end listeners must route new input to a later turn. - this.turnOpen = false - this.session.append('turn/end', { turn, reason }) - } - } catch (error: unknown) { - retryFailures = undefined - this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + /* v8 ignore next -- kick owns a running phase until this driver boundary */ + if (this.phase.kind === 'running') { + this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) } - // cancel() aborts but never clears the slot, and no second run can - // install a controller while this one is still unwinding, so the slot - // is still this run's controller here. - this.abort = undefined - signal.removeEventListener('abort', cancelRetry) - const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort - this.preservePendingAdmissionsOnAbort = false - // oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited. - if (!preservePending) this.rejectPendingAdmissions() - } - - if (opened) { - try { - await this.loopCtx.sessions.flush(this.session) - } catch (error: unknown) { - this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - } - } - - if (retryFailures !== undefined) { - await this.run({ kind: 'retry' }, [], 0, retryFailures) - } else { - // agent/settled names only committed turns: a run aborted or rejected - // before turn/start has no durable turn/end for consumers to settle - // against, so it exits without the notification. - if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason) - this.continueOrIdle() } } - /** - * Run the `agent/step` extension point, commit pending input, derive one - * request, and execute its tool calls inside one durable step boundary. - */ - private async step( - turn: number, - step: number, - signal: AbortSignal, - ): Promise<StepOutcome> { - const { session } = this - - // The single between-steps extension point: listeners inject, steer, or - // edit the log here; the request derives from the log after this settles. - await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) - signal.throwIfAborted() - - // Assemble request-owned prompt inputs fresh each step. Dynamic context is - // committed at the tail before deriving history once, preserving the stable - // system/history cache prefix while keeping every model-visible byte logged. + private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> { + /* v8 ignore next -- private callers establish the running phase before proposing a step */ + if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`) + const signal = this.phase.abort.signal + const claimed = this.inbox.claim(target, position.turn) const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() - const system = renderPrompt(assembly) - materializeRuntimeContext(session, renderContextSnapshot(assembly)) - - // Commit the exact pending batch only after every asynchronous - // pre-request contribution succeeded. Input accepted after this splice - // remains pending for a later request. - this.drainOutbox(turn) - - // Snapshot the exact log prefix: the reconstruction boundary. Appends - // after this synchronous snapshot join the next request. - const boundaryMessages = session.deriveMessages() - - session.append('step/start', { turn, step }) - this.stepOpen = true - this.admitPendingAdmissions(turn, step) - signal.throwIfAborted() - - const { request, preparedCall } = await this.buildRequest( - turn, step, assembly.tools, system, boundaryMessages, signal, + const sections = renderContextSections(assembly) + const context = this.runtimeContext.project(joinContextSections(sections), sections) + const decision = await agentEvents(this.loopCtx, this).waterfall( + 'agent/pre-step', claimed, { ...position, signal }, + () => Promise.resolve({ + kind: 'enter', + messages: context === undefined ? claimed : [...claimed, context], + }), ) + signal.throwIfAborted() + return decision.kind === 'reject' ? decision : { ...decision, assembly } + } - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) + /** Open one turn before claiming its first proposed step. */ + private async turn(): Promise<boolean> { + if (this.phase.kind !== 'running') { + this.throwError(new Error(`agent "${this.id}": turn without driver reservation`)) + } + const phase = this.phase + const { signal } = phase.abort + signal.throwIfAborted() + const turn = phase.turn + 1 try { - for await (const chunk of stream) { + this.session.append('turn/start', { turn }) + } catch (error: unknown) { + this.throwError(error) + } + phase.turn = turn + let turnEnds: TurnEndReason | null = null + let target: InboxTarget = 'next-turn' + try { + while (true) { signal.throwIfAborted() - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) - chunkSeqs.push(chunkEvent.seq) - assembler.push(chunk) + const step = phase.step + 1 + const decision = await this.preStep(target, { turn, step }) + if (decision.kind === 'reject') { + turnEnds = { kind: 'blocked' } + return false + } + if (turnEnds && decision.messages.length === 0) break + // A removed waking message or an enter decision rewritten to empty + // still owns the initial turn boundary, but it spends no model call. + if (phase.step === 0 && decision.messages.length === 0) { + turnEnds = { kind: 'completed' } + return false + } + signal.throwIfAborted() + this.session.append('step/start', { turn, step }) + phase.step = step + try { + for (const message of decision.messages) { + this.session.append('user/message', message, { surfaceOp: 'append' }) + } + // max-tokens is sticky: once any step hits the ceiling, later steps + // that complete normally must not downgrade the turn outcome. + const stepEnd = await this.step(decision.assembly) + // max-tokens stays sticky: a later completed step must not + // downgrade the turn outcome. + if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd + } finally { + this.session.append('step/end', { turn, step }) + } + signal.throwIfAborted() + if (turnEnds && this.inbox.nextStep.length === 0) { + await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) + signal.throwIfAborted() + } + if (turnEnds && this.inbox.nextStep.length === 0) break + target = 'next-step' } } catch (error: unknown) { - const facts = llmFailureOf(stream, error) - if (facts !== undefined && error instanceof Error) { - return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) } + if (signal.aborted) { + turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause } + throw error + } + // Every failure is structured: an `LlmError` keeps its facts, anything + // else flattens to `errorChain` text under the `UNKNOWN` code. + turnEnds = { + kind: 'error', + error: error instanceof LlmError + ? error.failure + : { message: errorChain(error), code: 'UNKNOWN' }, + } + this.throwError(error) + } finally { + try { + // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending + this.session.append('turn/end', { turn, reason: turnEnds! }) + } catch (error: unknown) { + this.throwError(error) } - throw error } + if (!this.inbox.hasPending) return false + phase.abort = new AbortController() + phase.step = 0 + return true + } + + private async step(assembly: PromptAssembly): Promise<StepEndReason | null> { + /* v8 ignore next -- private callers establish the running phase before executing a step */ + if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`) + const { turn, step, abort: { signal } } = this.phase signal.throwIfAborted() + const system = renderPrompt(assembly) - // Failure finish chunks take the same path as thrown stream errors. - const finish = assembler.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { - const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure) - return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) } - } + while (true) { + const { request, preparedCall } = await this.buildRequest( + turn, step, assembly.tools, system, this.session.deriveMessages(), signal, + ) + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) + signal.throwIfAborted() + for await (const chunk of stream) { + signal.throwIfAborted() + chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) + assembler.push(chunk) + } + signal.throwIfAborted() + const finish = assembler.finish + if (finish.kind === 'error' || finish.kind === 'aborted') { + const action = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/request-error', this, { + turn, + step, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + }, signal, + () => Promise.resolve<RequestErrorAction>(undefined), + ) + signal.throwIfAborted() + if (action?.kind !== 'retry') { + throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) + } + continue + } - // Truncated (max-tokens) output cannot owe tool calls. - const assembled = assembler.blocks() - const content = finish.kind === 'max-tokens' - ? assembled.filter(block => block.type !== 'tool-call') - : assembled - const message: AssistantMessage = createAssistantMessage({ - content, - source: { - provider: request.provider, - model: request.model, - ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, - }, - }) + const message = createAssistantMessage({ + content: assembler.blocks(), + source: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + }) + this.session.append( + 'assistant/message', + { + turn, + step, + message, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) + if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } - session.append( - 'assistant/message', - { - turn, - step, - message, - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - - const toolCalls = content.filter(block => block.type === 'tool-call') - let concluded = false - if (toolCalls.length > 0) { - ({ concluded } = await executeToolCalls( + const toolCalls = message.content.filter(block => block.type === 'tool-call') + if (toolCalls.length === 0) return { kind: 'completed' } + const { concluded } = await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, - context => this.outbox.push({ message: freezeMessage(context), steering: false }), - )) - } - - // Ordinary context keeps the base loop's result-adjacent commit point. - // Steering remains provisional until the next request snapshot admits it. - this.drainOutboxContexts() - session.append('step/end', { turn, step }) - this.stepOpen = false - return { - kind: 'completed', - continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering), - concluded, - maxTokens: finish.kind === 'max-tokens', + context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), + ) + return concluded ? { kind: 'completed' } : null } } @@ -833,8 +419,7 @@ export class ReactLoopAgent implements Agent { preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal) config = preparedCall.config } catch (error: unknown) { - // A llm/stream listener may own and short-circuit a route with no - // adapter. Terminal dispatch still raises NO_ADAPTER when none does. + // Middleware may serve an unregistered route; terminal dispatch still requires an adapter. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error config = proposedConfig } @@ -846,191 +431,36 @@ export class ReactLoopAgent implements Agent { ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) - const baseline = session.requestHeader() + const baseline = this.session.requestHeader() if (!this.requestHeaderLogged) { - session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) + this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { - session.append('request/header', { header, reason: 'change' }) + this.session.append('request/header', { header, reason: 'change' }) } - // TODO: This looks like code smell. - // Context metadata for the route this request resolved to, recorded from the same - // registration-bound lookup that prepared the call (no second resolve). - // A route with unknown capacity is still recorded so it clears any older - // denominator; an unchanged route logs nothing. const contextWindow = preparedCall?.context?.contextWindow const requestContext: RequestContext = { provider: config.provider, model: config.model, ...contextWindow === undefined ? {} : { contextWindow }, } - const previous = session.requestContext() - if (previous?.provider !== requestContext.provider - || previous.model !== requestContext.model - || previous.contextWindow !== requestContext.contextWindow) { + const previousContext = session.requestContext() + if (previousContext?.provider !== requestContext.provider + || previousContext.model !== requestContext.model + || previousContext.contextWindow !== requestContext.contextWindow) { session.append('request/context', requestContext) } + signal.throwIfAborted() const request = markAgentLoopRequest(deepFreeze({ ...header.config, messages: boundaryMessages, ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, - sessionId: session.id, + sessionId: this.session.id, signal, })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - - /** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */ - private drainOutbox(turn: number, limit = this.outbox.length): void { - const batch = this.outbox.splice(0, limit) - for (let index = 0; index < batch.length; index += 1) { - const item = batch[index] - /* v8 ignore next -- the index walks the exact array length. */ - if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`) - try { - if (item.steering) { - /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ - if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) - this.session.append( - 'steering/message', - { turn, message: item.message }, - { surfaceOp: 'append' }, - ) - if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery) - } else { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) - } - } catch (error: unknown) { - item.delivery?.settle({ status: 'rejected' }) - this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index)) - throw error - } - } - } - - /** Commit ordinary context while retaining provisional steering in order. */ - private drainOutboxContexts(): void { - const pending = this.outbox - this.outbox = [] - for (let index = 0; index < pending.length; index += 1) { - const item = pending[index] - /* v8 ignore next -- the index walks the exact array length. */ - if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`) - if (item.steering) { - this.outbox.push(item) - continue - } - try { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) - } catch (error: unknown) { - this.outbox.push(...pending.slice(index)) - throw error - } - } - } - - /** Settle every committed steering item captured by this immutable request. */ - private admitPendingAdmissions(turn: number, step: number): void { - const outcome: SteeringOutcome = { status: 'admitted', turn, step } - for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome) - } - - /** Reject committed steering that left the inbox without reaching a request. */ - private rejectPendingAdmissions(): void { - for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' }) - } - - /** Discard uncommitted steering while retaining same-boundary injected context. */ - private discardOutboxSteering(): void { - const contexts: typeof this.outbox = [] - const discarded: InboxItem[] = [] - for (const item of this.outbox) { - if (!item.steering) { - contexts.push(item) - continue - } - item.delivery?.settle({ status: 'rejected' }) - /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ - if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) - discarded.push(item.item) - } - this.outbox = contexts - if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) - } - - /** - * Give context-only input its ordinary idle placement when admission - * produces no turn. Steering keeps the whole boundary staged so context - * accepted beside it cannot split from the request it accompanies. - */ - private flushRejectedAdmissionContexts(): void { - if (this.outbox.some(item => item.steering)) return - const contexts = this.outbox.splice(0) - for (let index = 0; index < contexts.length; index += 1) { - const item = contexts[index] - /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */ - if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed') - try { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) - } catch (error: unknown) { - this.outbox.unshift(...contexts.slice(index)) - throw error - } - } - } - - /** - * The single settlement funnel: classify one turn failure (interruption - * beats error) into the durable turn/end reason and live settlement report. - */ - private settle( - turn: number, - step: number, - error: unknown, - signal: AbortSignal, - failure?: LlmFailure, - ): { reason: TurnEndReason; settleReason: SettleReason } { - if (signal.aborted) { - // Slot invariant, stated rather than re-validated: the turn controller - // is machine-private and cancel() is its only aborter, always with one - // frozen canonical cause as the reason. - const interrupt = signal.reason as AgentInterruptReason - return { - reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, - settleReason: { kind: 'aborted' }, - } - } - if (failure !== undefined) { - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - // The durable record renders the full cause chain: turn/end is the one - // durable trace of the failure, so a wrapper message alone would lose - // the transport detail the log exists to keep. - const rendered = errorChain(error) - return { - reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } }, - settleReason: { kind: 'error', error, failure }, - } - } - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - return { - reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} }, - settleReason: { kind: 'error', error }, - } - } - - /** Continue with a waking prompt, or publish the idle status. */ - private continueOrIdle(): void { - if (this.runnableWakingQueued) { - this.kick() - } else { - // Every caller sits inside an admission or run whose install marked the - // interval busy, so the flag is still set here. - this.busy = false - emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') - } - } } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 54b8f5c957..3f77973d92 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -14,12 +14,13 @@ import type { AgentFactory, AgentHandle, AgentOptions, + AgentSetup, CreateAgentOptions, ResumeAgentOptions, SessionStartSource, } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -103,6 +104,30 @@ async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, } } +/** Start an abortable operation and release a value that arrives after cancellation. */ +async function raceAbortCall<T>( + operation: () => PromiseLike<T> | T, + signal: AbortSignal, + id: SessionId, + releaseAbandoned?: (value: T) => void, +): Promise<T> { + if (signal.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) + } + const pending = Promise.resolve().then(operation) + try { + return await raceAbort(pending, signal, id) + } catch (error: unknown) { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited. + if (signal.aborted && releaseAbandoned !== undefined) { + void pending.then(releaseAbandoned, () => undefined) + } + throw error + } +} + /** Resolve the deployment-wide scheduler cap at the owning config boundary. */ function resolveMaxParallelToolCalls(value: number | undefined): number { const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS @@ -441,18 +466,7 @@ export class AgentLoop extends Service implements AgentFactory { if (machine === undefined) await machineReady.promise if (machine !== undefined) { machine.cancel({ kind: 'disposed' }) - // Drain to TRUE quiescence: cancel's own synchronous event chain - // (running→idle) can legitimately re-enter through an automation - // listener (goal-session's idle drive) and replace `done` with a - // fresh admission before this await captures it. The replacement - // work is cancelled and drained in turn until the slot stabilizes. - let done = machine.done - while (true) { - await Promise.allSettled([done]) - if (machine.done === done) break - done = machine.done - machine.cancel({ kind: 'disposed' }) - } + await machine.whenIdle() await machine.scope.dispose() } } finally { @@ -509,7 +523,7 @@ export class AgentLoop extends Service implements AgentFactory { loopCtx.agents.announce(agent) assertLive() // A synchronous announce/session-start listener may have started - // teardown; the machine is already live (send() works from the + // teardown; the machine is already live (delivery works from the // session-start seam), so only the liveness recheck is owed. emitAgentEvent(loopCtx, agent, 'agent/session-start', source) assertLive() @@ -534,8 +548,8 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent { - const session = this.runtime.ctx.sessions.prepare(id, { meta }) - const prepared = this.prepare(this.ctx, id, options, session) + using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })) + const prepared = this.prepare(this.ctx, id, options, preparation.session) try { return prepared.publish('startup').agent } catch (error: unknown) { @@ -551,27 +565,46 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> { - const session = this.runtime.ctx.sessions.prepare(options.sessionId, { + const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, - }) - const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) - const published = (async () => { - try { - const setupCommit = await raceAbort( - options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId, - ) - setupCommit?.commit() - return prepared.publish('startup') - } catch (error: unknown) { - await prepared.dispose() - throw error - } - })() + })) + const published = this.setupAndPublish( + ownerCtx, + options.sessionId, + preparation, + options.agentOptions ?? {}, + options.setup, + options.signal, + 'startup', + ) this.ownership.trackWrapper(published) return published } + /** Prepare one Agent around an acquired Session, run setup, and publish it. */ + private async setupAndPublish( + ownerCtx: Context, + id: SessionId, + preparation: SessionPreparation, + agentOptions: AgentOptions, + setup: AgentSetup | undefined, + signal: AbortSignal | undefined, + source: SessionStartSource, + ): Promise<AgentHandle> { + using ownedPreparation = preparation + const session = ownedPreparation.session + const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal) + try { + const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id) + setupCommit?.commit() + return prepared.publish(source) + } catch (error: unknown) { + await prepared.dispose() + throw error + } + } + /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. @@ -606,26 +639,31 @@ export class AgentLoop extends Service implements AgentFactory { ownerAbort.signal, this.ownership.signal, ]) - let loaded: Awaited<ReturnType<SessionPersistence['load']>> + let preparation: SessionPreparation | undefined try { - loaded = await raceAbort(persistence.load(id), fused, id) + try { + preparation = await raceAbortCall( + () => persistence.prepare(id, fused), + fused, + id, + (abandoned) => { abandoned[Symbol.dispose]() }, + ) + } finally { + await unfollowOwner() + } + ownerCtx.fiber.assertActive() + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + return await this.setupAndPublish( + ownerCtx, + id, + preparation, + options.agentOptions ?? {}, + options.setup, + options.signal, + 'resume', + ) } finally { - await unfollowOwner() - } - ownerCtx.fiber.assertActive() - if (!this.ownership.isActive()) throw new Error('agent loop is not active') - const session = this.runtime.ctx.sessions.prepare(id, { - seed: loaded.events, - meta: loaded.meta, - }) - const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) - try { - const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) - setupCommit?.commit() - return prepared.publish('resume') - } catch (error: unknown) { - await prepared.dispose() - throw error + preparation?.[Symbol.dispose]() } })() this.ownership.trackWrapper(published) diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 5d96efc70c..d87655d1fc 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { foldRequestHeader } from '@deepseek-ai/dsh-session' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' @@ -17,8 +17,7 @@ export const inject = ['invariants'] /** Install the request-reconstruction contribution into its child registration fiber. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - // Prepend prevents a short-circuiting replay listener from silencing the - // check; correctness itself comes from the sequence-bounded reconstruction. + // Prepend prevents a short-circuiting replay listener from silencing the check. ctx.on('llm/stream', (options: GenerateOptions, next) => { if (!isAgentLoopRequest(options)) return next() if (!Object.isFrozen(options)) fail('a loop-built request must be frozen') @@ -30,27 +29,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } const events = session.events - let boundary = -1 - for (let index = events.length - 1; index >= 0; index -= 1) { - if (events[index]?.type === 'step/start') { - boundary = index - break - } - } - if (boundary === -1) { + if (!events.some(event => event.type === 'step/start')) { return fail('a loop-built request with no step/start in its session log') } const header = foldRequestHeader(events) if (header === undefined) { return fail('a loop-built request with no request/header event in its session log') } - const rebuilt = new Session( - SessionId(`${String(session.id)}-invariant-rebuild`), - structuredClone(events.slice(0, boundary)), - ) - const expected = rebuilt.deriveMessages() + const expected = session.deriveMessages() if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { - fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) + fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`) } const headerMatches = options.model === header.config.model diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts new file mode 100644 index 0000000000..8cf4a41403 --- /dev/null +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -0,0 +1,76 @@ +/** + * Durable projection state for dynamic runtime context. + * @module @deepseek-ai/dsh-agent-loop/runtime-context + */ + +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm' +import type { Session, UserMessage } from '@deepseek-ai/dsh-session' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { Context } from 'cordis' + +const SOURCE = '@deepseek-ai/dsh-system-prompt' +const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' + +function isOwned(message: UserMessage): boolean { + return message.source.kind === 'plugin' && message.source.plugin === SOURCE +} + +function textOf(message: UserMessage): string | undefined { + const [block] = message.content + return message.content.length === 1 && block?.type === 'text' ? block.text : undefined +} + +/** Tracks the last retained runtime-context snapshot without owning its commit. */ +export class RuntimeContextProjection { + /** `undefined` means no snapshot ever existed; `null` means none is retained. */ + private retained: { seq: number; text: string | undefined } | null | undefined + + /** + * Restore projection state once, then follow authoritative session events. + * @param ctx - agent-scoped event context. + * @param session - session receiving projected messages. + */ + constructor(ctx: Context, session: Session) { + const surface = new Set(session.surface.nodes) + for (let index = session.events.length - 1; index >= 0; index -= 1) { + const event = session.events[index] + if (event?.type !== 'user/message' || !isOwned(event.data)) continue + this.retained ??= null + if (surface.has(event.seq)) { + this.retained = { seq: event.seq, text: textOf(event.data) } + break + } + } + + ctx.on('session/event', (subject, event) => { + if (subject !== session) return + if (event.type === 'user/message' && isOwned(event.data)) { + this.retained = { seq: event.seq, text: textOf(event.data) } + } else if (this.retained + && isReplacementSurfaceEvent(event) + && event.sourceEventSeqs?.includes(this.retained.seq) === true) { + this.retained = null + } + }) + } + + /** + * Create an uncommitted snapshot only when the retained value differs. + * @param current - fully rendered dynamic context. + * @param sections - named contributions that formed the current snapshot. + * @returns a candidate user message, or `undefined` when no update is needed. + */ + project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined { + if (this.retained === undefined && current.length === 0) return + const snapshot = current.length === 0 ? CLEARED : current + if (this.retained?.text === snapshot) return + return createUserMessage({ + content: [{ type: 'text', text: snapshot }], + // The cleared marker has no contributions left to attribute. + source: sections.length === 0 + ? { kind: 'plugin', plugin: SOURCE } + : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections }, + }) + } +} diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 068c5c8c21..47082dac18 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -42,7 +42,7 @@ interface GroupOutcome { * Ordinary completion and abort commit started-call results in order. Abort * drains them, records synthetic results for unstarted calls, and returns with * the signal still aborted after accepting started-call context through the - * caller-supplied acceptor (the machine stages it on its outbox for the next + * caller-supplied acceptor (the machine stages it in its next-step inbox for the * step boundary). An internal scheduler failure stops new dispatches, drains * already-started dispatches, and rejects with the first failure without * fabricating tool results. diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index f3d784679f..bbc3e548e2 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => { const { ctx } = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) let signals: AbortSignal[] = [] - let admissionSignals: AbortSignal[] = [] + let preStepSignals: AbortSignal[] = [] const capture = (signal: AbortSignal | undefined): void => { if (signal === undefined) throw new Error('turn seam omitted its explicit signal') expect(ctx.agents.requireInitiator()).toBe(agent) @@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => { if (context.agent === agent) capture(context.signal) return next() }) - ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { + ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { if (subject === agent) { expect(ctx.agents.requireInitiator()).toBe(agent) - admissionSignals.push(signal) + preStepSignals.push(signal) } return next() }) - ctx.on('agent/step', (subject, _turn, _step, signal) => { - if (subject === agent) capture(signal) - }) ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) capture(signal) return next() @@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => { const firstSignal = signals[0] expect(firstSignal).toBeDefined() expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal])) - expect(admissionSignals).toHaveLength(1) - expect(admissionSignals[0]).not.toBe(firstSignal) + expect(preStepSignals).toHaveLength(2) + expect(new Set(preStepSignals)).toEqual(new Set([firstSignal])) signals = [] - admissionSignals = [] + preStepSignals = [] const secondIdle = waitForIdle(ctx, agent) send(agent, 'second') await secondIdle const secondSignal = signals[0] expect(secondSignal).toBeDefined() expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal])) - expect(admissionSignals).toHaveLength(1) - expect(admissionSignals[0]).not.toBe(secondSignal) + expect(preStepSignals).toHaveLength(1) + expect(preStepSignals[0]).toBe(secondSignal) expect(secondSignal).not.toBe(firstSignal) expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index dfa0a91098..1692f19291 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -26,51 +26,17 @@ function send(agent: Agent, text: string): void { } describe('Agent', () => { - it('does not echo caller-owned message identities from delivery methods', async () => { - const adapter = new MockAdapter([ - textResponse('one'), - textResponse('two'), - textResponse('three'), - ]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const message = (text: string) => createUserMessage({ - content: [{ type: 'text' as const, text }], - source: { kind: 'user' as const }, - }) - const call = (method: 'send' | 'inject' | 'followup' | 'steer', args: unknown[]): unknown => { - const implementation: unknown = Reflect.get(agent, method) - if (typeof implementation !== 'function') throw new Error(`missing Agent.${method}`) - return Reflect.apply(implementation, agent, args) - } - - expect(call('send', [message('quiet'), { - target: 'next-turn', - wakeup: false, - }])).toBeUndefined() - expect(call('inject', [message('context')])).toBeUndefined() - expect(call('followup', [message('followup')])).toBeUndefined() - const receipt = agent.steer(message('steering')) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(3) - expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 }) - }) - - it('idle inject() appends context without opening a turn or requesting a flush', async () => { + it('idle inject() durably stages context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) - expect(agent.session.events.map(event => event.type)).toEqual(['user/message']) + expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) await agent.whenIdle() - expect(flushes).toBe(0) }) it('inject() preserves an explicitly empty plugin source', async () => { @@ -80,11 +46,49 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) const injected = agent.session.events.at(-1) - expect(injected?.type === 'user/message' && injected.data.source) + expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) }) - it('idle inject() rejects invalid input before append', async () => { + it('emits exact inserted, claimed, and discarded inbox messages', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' }) + const inserted: unknown[] = [] + const claimed: unknown[] = [] + const discarded: unknown[] = [] + const lifecycle: string[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') + }) + ctx.on('agent/inbox/inserted', (subject, event) => { + if (subject === agent) inserted.push(event) + }) + ctx.on('agent/inbox/claimed', (subject, event) => { + if (subject === agent) { + lifecycle.push('agent/inbox/claimed') + claimed.push(event) + } + }) + ctx.on('agent/inbox/discarded', (subject, event) => { + if (subject === agent) discarded.push(event) + }) + const context = createUserMessage({ + content: [{ type: 'text', text: 'discard me' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + agent.inject(context) + agent.inbox.remove(context.id) + const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } }) + agent.followup(prompt) + await agent.whenIdle() + + expect(inserted).toEqual([{ message: context }, { message: prompt }]) + expect(discarded).toEqual([{ message: context }]) + expect(claimed).toEqual([{ message: prompt, turn: 1 }]) + expect(lifecycle).toEqual(['turn/start', 'agent/inbox/claimed']) + }) + + it('idle inject() rejects invalid input before enqueue', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -120,79 +124,6 @@ describe('Agent', () => { expect(statuses).toEqual(['running', 'idle']) }) - it('awaits the turn-end checkpoint before claiming the next queued turn', async () => { - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const firstFlush = Promise.withResolvers<undefined>() - const flushedTurns: number[] = [] - ctx.on('session/flush', async (session) => { - const turnEnd = session.events.findLast(event => event.type === 'turn/end') - flushedTurns.push(turnEnd?.data.turn ?? 0) - if (turnEnd?.data.turn === 1) await firstFlush.promise - }) - - send(agent, 'first') - send(agent, 'second') - - await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) }) - expect(adapter.requests).toHaveLength(1) - firstFlush.resolve(undefined) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(2) - expect(flushedTurns).toEqual([1, 2]) - }) - - it('keeps whenIdle pending through the final turn checkpoint', async () => { - const ctx = await harness(new MockAdapter([textResponse('done')])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const flush = Promise.withResolvers<undefined>() - let flushStarted = false - ctx.on('session/flush', () => { - flushStarted = true - return flush.promise - }) - - send(agent, 'go') - await vi.waitFor(() => { expect(flushStarted).toBe(true) }) - let idleSettled = false - const idle = agent.whenIdle().then(() => { idleSettled = true }) - await Promise.resolve() - expect(idleSettled).toBe(false) - - flush.resolve(undefined) - await idle - expect(agent.status).toBe('idle') - }) - - it('reports a rejected turn-end checkpoint and continues queued work', async () => { - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(adapter) - const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const failure = new Error('disk unavailable') - const errors: { turn: number; step: number; error: unknown }[] = [] - let flushes = 0 - ctx.on('session/flush', () => { - flushes += 1 - if (flushes === 1) throw failure - }) - ctx.on('agent/error', (subject, turn, step, error) => { - if (subject === agent) errors.push({ turn, step, error }) - }) - - send(agent, 'first') - send(agent, 'second') - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(2) - expect(flushes).toBe(2) - expect(errors).toEqual([{ turn: 1, step: 1, error: failure }]) - expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable')) - warning.mockRestore() - }) - it('whenIdle() resolves immediately without active work', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index ec55e2c76b..87f2d0991d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,13 +1,13 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears - * queued and steering work, while `keepInbox` preserves pending input and - * resumes waking turns after the active turn reaches quiescence. The suite + * queued and steering work, while `keepInbox` preserves pending input for a + * later wake after the active turn reaches quiescence. The suite * covers every landing window plus signal reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { - it('notifies every observer before clearing work and contains listener failures', async () => { - const adapter = new MockAdapter([textResponse('must remain unused')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) - const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: string[] = [] - ctx.on('agent/cancel-requested', (subject, cause) => { - if (subject !== agent) return - seen.push(`first:${cause.kind}`) - subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })) - throw new Error('observer failed') - }) - ctx.on('agent/cancel-requested', (subject, cause) => { - if (subject === agent) seen.push(`second:${cause.kind}`) - }) - - send(agent, 'drop me') - agent.cancel({ kind: 'user' }) - await new Promise(resolve => setTimeout(resolve, 30)) - agent.cancel({ kind: 'parent' }) - - expect(seen).toEqual(['first:user', 'second:user']) - expect(userTexts(agent)).toEqual([]) - expect(adapter.requests).toHaveLength(0) - expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) - }) - it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) @@ -99,77 +72,76 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) }) - it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => { - const adapter = new MockAdapter([textResponse('reply')]) + it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => { + const adapter = new MockAdapter([textResponse('wake reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const discards: unknown[] = [] - ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) - const cancelRequests: unknown[] = [] - ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) }) - // Queue a turn WITHOUT waking the driver, so it sits in the inbox. - agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) - // keepInbox cancel: no active turn, work preserved, no discard event. With - // nothing to abort and nothing discarded, the call is a documented no-op, - // so it emits no cancel-requested either. + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'preserved' }], + source: { kind: 'user' }, + })) + // A waking send starts and claims synchronously, so keepInbox has no + // pending item to preserve by the time this cancellation runs. agent.cancel({ kind: 'user' }, { keepInbox: true }) - expect(discards).toEqual([]) - expect(cancelRequests).toEqual([]) - - // The preserved item still runs once the driver is woken by a later send. - send(agent, 'wake it') - await waitForIdle(ctx, agent) - expect(userTexts(agent)).toEqual(['preserved', 'wake it']) - }) - - it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { - const adapter = new MockAdapter([textResponse('reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // A quiet item alone must NOT wake the driver: no turn runs and whenIdle - // resolves (the agent is quiescent), leaving the item queued. - agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) + expect(agent.session.events.some(event => + event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false) await agent.whenIdle() - expect(agent.status).toBe('idle') - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) - // A later waking send drives the loop, and the quiet item rides along first. - send(agent, 'wake') - await waitForIdle(ctx, agent) - expect(userTexts(agent)).toEqual(['quiet', 'wake']) - }) - - it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => { - const adapter = new MockAdapter([textResponse('reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) - const idle = agent.whenIdle() - agent.cancel({ kind: 'user' }) + const idle = waitForIdle(ctx, agent) + send(agent, 'wake it') await idle - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(userTexts(agent)).toEqual(['wake it']) + expect(adapter.requests).toHaveLength(1) }) - it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { + it('cancel({ keepInbox: true }) parks queued work after an active turn aborts', async () => { + const adapter = new MockAdapter([ + 'hang', + textResponse('preserved reply'), + textResponse('wake reply'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('keep-after-abort'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + send(agent, 'preserved') + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(agent.inbox.nextTurn).toHaveLength(1) + expect(adapter.requests).toHaveLength(1) + + const idle = waitForIdle(ctx, agent) + send(agent, 'wake it') + await idle + expect(userTexts(agent)).toEqual(['active', 'preserved', 'wake it']) + expect(adapter.requests).toHaveLength(3) + }) + + it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // send() queues synchronously (status still idle, loop microtask not yet - // resumed). Cancel in that pre-step window: the queued turn must not run. send(agent, 'drop me first') send(agent, 'drop me second') agent.cancel({ kind: 'user' }) - // Give the loop a chance to wake and process the cancel. await new Promise(r => setTimeout(r, 30)) - // No turn was opened — the queued prompt was dropped, never recorded. expect(userTexts(agent)).toEqual([]) - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0) + expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') }) @@ -247,7 +219,7 @@ describe('Agent.cancel()', () => { await expect(Promise.race([ replacementObservation, new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)), - ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 }) + ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 2 }) const idle = waitForIdle(ctx, agent) send(agent, 'later') @@ -256,8 +228,12 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) - it('replacement work queued after idle-listener cancellation still runs', async () => { - const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => { + const adapter = new MockAdapter([ + textResponse('first reply'), + textResponse('replacement reply'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) @@ -277,8 +253,15 @@ describe('Agent.cancel()', () => { if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') await replacementIdle - expect(adapter.requests).toHaveLength(2) - expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['first']) + expect(agent.inbox.nextTurn).toHaveLength(1) + + const idle = waitForIdle(ctx, agent) + send(agent, 'wake it') + await idle + expect(adapter.requests).toHaveLength(3) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement', 'wake it']) }) it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { @@ -296,47 +279,12 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) - it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => { - const adapter = new MockAdapter([ - 'hang', - textResponse('second reply'), - textResponse('third reply'), - ]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' }) - const reasons: TurnEndReason[] = [] - const discards: unknown[] = [] - ctx.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) - }) - ctx.on('agent/inbox/discard', (subject, items) => { - if (subject === agent) discards.push(items) - }) - - send(agent, 'active') - await new Promise(resolve => setTimeout(resolve, 30)) - send(agent, 'queued second') - send(agent, 'queued third') - const idle = agent.whenIdle() - agent.cancel({ kind: 'user' }, { keepInbox: true }) - await idle - - expect(discards).toEqual([]) - expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third']) - expect(reasons).toEqual([ - { kind: 'aborted' }, - { kind: 'completed' }, - { kind: 'completed' }, - ]) - expect(adapter.requests).toHaveLength(3) - }) - it('cancel from an assistant/message observer skips execution but balances replay', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'danger', {}), @@ -368,7 +316,7 @@ describe('Agent.cancel()', () => { dispose() expect(executions).toBe(0) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) const call = agent.session.events.find(event => event.type === 'tool/call') const result = agent.session.events.find(event => event.type === 'tool/result') expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') @@ -387,7 +335,7 @@ describe('Agent.cancel()', () => { .find(block => block.type === 'tool-result') expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true }) expect(reasons).toEqual([ - { kind: 'aborted' }, + { kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }, ]) }) @@ -414,33 +362,6 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) - it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { - const adapter = new MockAdapter([textResponse('should not stream')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // A turn/start listener fires before a step controller exists, so the - // turn-scoped marker—not step abort—must drop the pending step. - let streamed = false - ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - const dispose = ctx.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' }) - }) - - const reasons: TurnEndReason[] = [] - ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - dispose() - - // No step streamed (the model never ran), and the turn ended aborted with - // the caller's cause — the marker carries `cancel(cause)` through even - // though no AbortController observed it in this window. - expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted' }]) - }) - it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) @@ -466,12 +387,12 @@ describe('Agent.cancel()', () => { // No step streamed, the turn ended with the coarse aborted outcome, and the // log is balanced (the open step was closed by the cancel branch). expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) const types = agent.session.events.map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) - it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => { + it('disposal from a synchronous step/start session-event listener stops before adapter dispatch', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = new Context() await ctx.plugin(LlmService) @@ -501,8 +422,7 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false) const types = agent.session.events.map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -533,7 +453,7 @@ describe('Agent.cancel()', () => { // Only ONE step ran (the second was cancelled in the stopping window), // and the shared turn signal classified the durable outcome as aborted. expect(steps).toBe(1) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) }) it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { @@ -541,8 +461,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // `agent/status` is synchronous, so cancellation can land after the first - // pre-step check; the second check must drop the now-empty turn. + // `agent/status` is synchronous, so cancellation can land before the + // durable turn-start commit and must drop the reserved work. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('agent/status', (subject, status) => { @@ -559,8 +479,7 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) - it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => { - // Cancellation must not settle idle while replacement work remains queued. + it('a running-listener cancellation parks replacement work until another wakeup', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -578,32 +497,35 @@ describe('Agent.cancel()', () => { await idle dispose() - // whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end - // are in the log, and A was dropped. - expect(userTexts(agent)).toContain('B') - expect(userTexts(agent)).not.toContain('A') - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + expect(userTexts(agent)).toEqual([]) + expect(agent.inbox.nextTurn).toHaveLength(1) + + const replacementIdle = waitForIdle(ctx, agent) + send(agent, 'C') + await replacementIdle + expect(userTexts(agent)).toEqual(['B', 'C']) + expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) }) - it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => { - // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A; - // prompt B is queued before the loop resumes from the idle wait. + it('a prompt queued during pre-step cancellation waits for another wakeup', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - send(agent, 'A') // queues A (status still idle, loop microtask pending) - const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) - agent.cancel({ kind: 'user' }) // arms marker, clears A - send(agent, 'B') // B races in before the loop resumes + send(agent, 'A') + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }) + send(agent, 'B') - // whenIdle() must resolve only after B's turn fully ran — by which point B's user message - // and a turn/end are in the log. await idle - expect(userTexts(agent)).toContain('B') - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) - // A was dropped (never ran); only B's turn is recorded. - expect(userTexts(agent)).not.toContain('A') + expect(userTexts(agent)).toEqual([]) + expect(agent.inbox.nextTurn).toHaveLength(1) + + const replacementIdle = waitForIdle(ctx, agent) + send(agent, 'C') + await replacementIdle + expect(userTexts(agent)).toEqual(['B', 'C']) + expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) }) it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { @@ -628,14 +550,18 @@ describe('Agent.cancel()', () => { expect(turnStarts.length).toBe(1) // only the original (cancelled) turn // The steering text was dropped — it never reached the log. const flat = agent.session.events - .filter(e => e.type === 'steering/message') - .flatMap(e => e.type === 'steering/message' ? e.data.message.content : []) + .filter(e => e.type === 'user/message') + .flatMap(e => e.data.content) .flatMap(b => b.type === 'text' ? [b.text] : []) expect(flat).not.toContain('steer text') }) - it('keeps replacement work queued synchronously by an abort observer', async () => { - const adapter = new MockAdapter(['hang', textResponse('replacement reply')]) + it('parks replacement work queued synchronously by an abort observer', async () => { + const adapter = new MockAdapter([ + 'hang', + textResponse('replacement reply'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' }) @@ -644,7 +570,7 @@ describe('Agent.cancel()', () => { const signal = adapter.requests[0]?.signal if (signal === undefined) throw new Error('model request omitted its turn signal') signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true }) - const idle = waitForIdle(ctx, agent) + const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await Promise.race([ idle, @@ -660,15 +586,22 @@ describe('Agent.cancel()', () => { }), ]) - expect(adapter.requests).toHaveLength(2) - expect(userTexts(agent)).toEqual(['original', 'replacement']) + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['original']) + expect(agent.inbox.nextTurn).toHaveLength(1) const reasons = agent.session.events .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) - expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + + const replacementIdle = waitForIdle(ctx, agent) + send(agent, 'wake it') + await replacementIdle + expect(adapter.requests).toHaveLength(3) + expect(userTexts(agent)).toEqual(['original', 'replacement', 'wake it']) }) - it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => { + it('keeps the first typed cause for an active turn', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' }) @@ -677,16 +610,17 @@ describe('Agent.cancel()', () => { send(agent, 'go') await expect.poll(() => adapter.requests.length).toBe(1) agent.cancel(supplied) - supplied.kind = 'user' agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) - expect(runtimeReason).not.toBe(supplied) - expect(Object.isFrozen(runtimeReason)).toBe(true) + expect(runtimeReason).toBe(supplied) const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ + kind: 'aborted', + reason: { kind: 'parent' }, + }) }) it('preserves the first user cancellation when lifecycle teardown races it', async () => { @@ -704,13 +638,12 @@ describe('Agent.cancel()', () => { await handle.dispose() const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) it.each([ - 'prompt-submit', + 'pre-step', 'system-prompt', - 'step', 'request', 'stopping', 'tool', @@ -730,8 +663,8 @@ describe('Agent.cancel()', () => { } switch (stage) { - case 'prompt-submit': - ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { + case 'pre-step': + ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) @@ -745,11 +678,6 @@ describe('Agent.cancel()', () => { return next() }) break - case 'step': - ctx.on('agent/step', async (subject, _turn, _step, signal) => { - if (subject === agent) await blockUntilAbort(signal) - }) - break case 'request': ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) await blockUntilAbort(signal) @@ -781,11 +709,8 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await idle const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - if (stage === 'prompt-submit') { - expect(turnEnd).toBeUndefined() - } else { - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) - } + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) await ctx.fiber.dispose() }) }) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c6459c7b39..c0be39e41b 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -146,8 +146,9 @@ describe('config-driven session id', () => { dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('saved')])) const sessionId = SessionId('config-exact-overlap') - const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const config = { agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() const first = ctx.agents.get(sessionId) as Agent @@ -158,7 +159,9 @@ describe('config-driven session id', () => { cleanupStarted.resolve(undefined) await cleanupGate.promise }) - first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } })) + const idle = waitForIdle(ctx, first) + first.followup(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'user' } })) + await idle await ctx.sessions.flush(first.session) expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) .toContain('persist before replacement') @@ -293,14 +296,15 @@ describe('config-driven session id', () => { }) it.each(['resolve', 'reject'] as const)( - 'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts', + 'abandons an exact-id preparation that later %s when AgentLoop disposal starts', async (outcome) => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>() - vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise) + const preparing = Promise.withResolvers<SessionPreparation>() + vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise) + const released = vi.fn() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) @@ -310,18 +314,15 @@ describe('config-driven session id', () => { }) await loop.dispose() if (outcome === 'resolve') { - loading.resolve({ - meta: { - id: SessionId('config-exact-dispose'), - version: 0, - createdAt: Date.now(), - }, - events: [], - }) + preparing.resolve(SessionPreparation.create( + ctx.sessions.prepare(SessionId('config-exact-dispose')), + { release: released }, + )) } else { - loading.reject(new Error('startup cancelled by teardown')) + preparing.reject(new Error('startup cancelled by teardown')) } await Promise.resolve() + if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce() expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 672b1aa597..ad3a3ef507 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { ReactLoopAgent } from '../src/agent.ts' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -53,211 +53,12 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } -function inboxText(item: InboxItem): string { - return item.message.content +function inboxText(message: UserMessage): string { + return message.content .flatMap(block => block.type === 'text' ? [block.text] : []) .join('') } -describe('addressable inbox operations', () => { - it('edits in place and removes exactly one queued item', async () => { - const adapter = new MockAdapter([ - textResponse('first reply'), - textResponse('edited reply'), - ]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' }) - const admission = Promise.withResolvers<undefined>() - const release = Promise.withResolvers<undefined>() - ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => { - if (message.content[0]?.type === 'text' && message.content[0].text === 'first') { - admission.resolve(undefined) - await release.promise - } - return next() - }) - - const pending: InboxItem[] = [] - const updates: { id: string; text: string }[] = [] - const discards: string[][] = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent && inboxText(item) !== 'first') pending.push(item) - }) - ctx.on('agent/inbox/update', (subject, item) => { - if (subject === agent) updates.push({ id: item.id, text: inboxText(item) }) - }) - ctx.on('agent/inbox/discard', (subject, items) => { - if (subject === agent) discards.push(items.map(item => item.id)) - }) - - send(agent, 'first') - await admission.promise - send(agent, 'remove me') - send(agent, 'edit me') - expect(pending.map(inboxText)).toEqual(['remove me', 'edit me']) - - const remove = pending[0]! - const edit = pending[1]! - expect(agent.updateInbox(edit.id, { - kind: 'edit', - content: [{ type: 'text', text: 'edited' }], - })).toBe('applied') - expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') - expect(updates).toEqual([{ id: edit.id, text: 'edited' }]) - expect(discards).toEqual([[remove.id]]) - - const idle = waitForIdle(ctx, agent) - release.resolve(undefined) - await idle - expect(agent.session.events - .filter(event => event.type === 'user/message') - .map(event => event.type === 'user/message' - ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') - : '')) - .toEqual(['first', 'edited']) - expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found') - }) - - it('does not mutate steering occurrences', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' }) - const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<{ kind: 'allow' }>() - ctx.on('agent/prompt-submit', async () => { - entered.resolve(undefined) - return decision.promise - }) - - const pending: InboxItem[] = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent && item.placement === 'steering') pending.push(item) - }) - - const idle = waitForIdle(ctx, agent) - send(agent, 'admitted prompt') - await entered.promise - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } })) - expect(pending.map(inboxText)).toEqual(['keep me']) - - const steering = pending[0]! - expect(agent.updateInbox(steering.id, { - kind: 'edit', - content: [{ type: 'text', text: 'edited' }], - })).toBe('not-found') - expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found') - - decision.resolve({ kind: 'allow' }) - await idle - expect(agent.session.events - .filter(event => event.type === 'steering/message') - .map(event => event.type === 'steering/message' - ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') - : '')) - .toEqual(['keep me']) - }) - - it('strictly transfers a queued occurrence into the open turn', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' }) - const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<{ kind: 'allow' }>() - ctx.on('agent/prompt-submit', async () => { - entered.resolve(undefined) - return decision.promise - }) - - const enqueued: InboxItem[] = [] - const discarded: InboxItem[] = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent) enqueued.push(item) - }) - ctx.on('agent/inbox/discard', (subject, items) => { - if (subject === agent) discarded.push(...items) - }) - - const idle = waitForIdle(ctx, agent) - send(agent, 'open the turn') - const receipt = agent.steer(createUserMessage({ - content: [{ type: 'text', text: 'steer this message' }], - source: { kind: 'user' }, - })) - await entered.promise - const queued = enqueued.find(item => inboxText(item) === 'steer this message')! - - expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied') - const steering = enqueued.find(item => item.placement === 'steering')! - expect(steering.id).not.toBe(queued.id) - expect(steering.message).toBe(queued.message) - expect(discarded).toEqual([queued]) - - decision.resolve({ kind: 'allow' }) - await idle - expect(agent.session.events.flatMap(event => - event.type === 'steering/message' ? [event.data.message] : [], - )).toEqual([queued.message]) - expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 }) - expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found') - }) - - it('keeps a queued occurrence when the next-step window is closed', () => { - const ctx = new Context() - const session = new Session(SessionId('queue-to-steer-closed')) - const agent = new ReactLoopAgent(ctx, session.id, {}, session) - const enqueued: InboxItem[] = [] - const discarded: InboxItem[] = [] - ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) }) - ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) }) - - agent.send( - createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }), - { target: 'next-turn', wakeup: false }, - ) - const queued = enqueued[0]! - expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable') - expect(discarded).toEqual([]) - expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied') - }) - - it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => { - const adapter = new MockAdapter([textResponse('unused')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' }) - const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<{ kind: 'allow' }>() - ctx.on('agent/prompt-submit', async () => { - entered.resolve(undefined) - return decision.promise - }) - - const enqueued: InboxItem[] = [] - const discarded: InboxItem[] = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject !== agent) return - enqueued.push(item) - if (item.placement === 'steering') agent.cancel({ kind: 'user' }) - }) - ctx.on('agent/inbox/discard', (subject, items) => { - if (subject === agent) discarded.push(...items) - }) - - const idle = waitForIdle(ctx, agent) - send(agent, 'open the turn') - await entered.promise - send(agent, 'cancel during conversion') - const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')! - - expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied') - const steering = enqueued.find(item => item.placement === 'steering')! - expect(discarded).toEqual([steering, queued]) - - decision.resolve({ kind: 'allow' }) - await idle - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) - }) -}) - describe('assistant replay provenance', () => { it('records adapter replay state with the assembled assistant content', async () => { const response = textResponse('unchanged') @@ -281,8 +82,11 @@ describe('assistant replay provenance', () => { }) describe('abort during tool execution ends the turn', () => { - it('records context accepted before a tool-step abort in the same turn', async () => { - const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) + it('parks context finalized after a tool-step abort until another wakeup', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'aborter', {}), + textResponse('after wake'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ @@ -306,14 +110,20 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.events] - expect(events + expect(agent.session.events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') .map(event => event.type)) - .toEqual(['tool/result', 'user/message', 'step/end', 'turn/end']) - expect(events + .toEqual(['tool/result', 'step/end', 'turn/end']) + expect(agent.inbox.nextStep.map(inboxText)) + .toEqual(['accepted result context after abort']) + + const idle = waitForIdle(ctx, agent) + send(agent, 'wake') + await idle + + expect(agent.session.events .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) @@ -377,7 +187,26 @@ describe('abort during tool execution ends the turn', () => { .toBeUndefined() }) - it('records result context finalized after disposal cancellation', async () => { + it('closes an empty admitted batch as a turn without a step', async () => { + const adapter = new MockAdapter([textResponse('must not run')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + if (subject !== agent) return next() + return Promise.resolve({ kind: 'enter', messages: [] }) + }) + send(agent, 'go') + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'turn/start' + || event.type === 'step/start' || event.type === 'turn/end').map(event => event.type)) + .toEqual(['turn/start', 'turn/end']) + expect(agent.session.events.find(event => event.type === 'turn/end')?.data) + .toEqual({ turn: 1, reason: { kind: 'completed' } }) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + + it('parks result context finalized after disposal cancellation without opening another turn', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) const ctx = await harness(adapter) const started = Promise.withResolvers<undefined>() @@ -417,11 +246,13 @@ describe('abort during tool execution ends the turn', () => { .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) - .toEqual([ - [{ type: 'text', text: 'accepted result context during disposal' }], - ]) + .toEqual([]) + expect(agent.inbox.nextStep.map(inboxText)) + .toEqual(['accepted result context during disposal']) + expect(agent.session.events.filter(event => event.type === 'turn/start')) + .toHaveLength(1) expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) - .toEqual({ kind: 'disposed' }) + .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) it('limits injection deferral to the current tool batch', async () => { @@ -457,10 +288,19 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - ctx.on('agent/step', (subject, turn) => { - if (subject === agent && turn === 2) { - agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } })) + const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { + const decision = await next() + if (subject === agent && turn === 2 && decision.kind === 'enter') { + disposeInjection() + return { + kind: 'enter' as const, + messages: [...decision.messages, createUserMessage({ + content: [{ type: 'text', text: 'new turn context' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + } } + return decision }) send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) @@ -499,65 +339,6 @@ describe('steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) - it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => { - // Assert the same-turn shape; content alone cannot distinguish re-enqueue. - const adapter = new MockAdapter([ - textResponse('no tools, would stop'), - textResponse('after goal reminder'), - ]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - let steeredOnce = false - ctx.on('session/event', (subject, event) => { - if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return - steeredOnce = true - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } })) - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const events = [...agent.session.events] - expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(events.filter(e => e.type === 'step/start')).toHaveLength(2) - // Same-turn steering precedes the second step. - const steeringIdx = events.findIndex(e => e.type === 'steering/message') - const step2Idx = events.map(e => e.type).lastIndexOf('step/start') - expect(steeringIdx).toBeGreaterThanOrEqual(0) - expect(steeringIdx).toBeLessThan(step2Idx) - // and it reached the next model request. - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') - }) - - it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { - const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const turns: number[] = [] - let steeredOnce = false - ctx.on('session/event', (subject, event) => { - if (subject !== agent.session) return - if (event.type === 'turn/start') turns.push(event.data.turn) - if (event.type === 'turn/end' && !steeredOnce) { - steeredOnce = true - expect(agent.acceptsNextStep).toBe(false) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })) - } - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - // the loop chains directly into turn 2 (status never returns to idle in - // between), so the first idle transition means both turns are complete - - expect(turns).toEqual([1, 2]) - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn') - }) - }) describe('plugin exceptions are contained', () => { @@ -574,14 +355,11 @@ describe('plugin exceptions are contained', () => { } }) - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) - send(agent, 'first') await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['broken continuation plugin']) + expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, + }) // the loop is still alive: a second send works normally send(agent, 'second') @@ -614,7 +392,7 @@ describe('disposal leaves the two-state status contract balanced', () => { await driverDone(agent) expect(statuses).toEqual(['running', 'idle']) - expect(reasons).toEqual([{ kind: 'disposed' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) const messages = agent.session.events .filter(event => event.type === 'user/message') @@ -663,16 +441,15 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) - send(agent, 'go') await waitForIdle(ctx, agent) - expect(errors).toHaveLength(1) - expect(errors[0]!.message).toContain('has no provider/model') - expect(errors[0]!.message).toContain('agent/request') + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' + ? turnEnd.data.reason.error.message + : undefined).toContain('has no provider/model') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' + ? turnEnd.data.reason.error.message + : undefined).toContain('agent/request') }) it('the agent/request waterfall can supply the model for a model-less agent', async () => { @@ -690,7 +467,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => { + it('durable inbox splices carry exact messages and the claimed steer preserves its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -704,30 +481,32 @@ describe('adapter registration, routing, and accepted-input ownership', () => { }, })) - const queuedSources: MessageSource[] = [] - const queuedShapes: string[][] = [] - const placements: InboxPlacement[] = [] - ctx.on('agent/inbox/enqueue', (_agent, item) => { - queuedSources.push(item.message.source) - queuedShapes.push(Object.keys(item.message).sort()) - placements.push(item.placement) + const insertedSources: MessageSource[] = [] + const insertedShapes: string[][] = [] + const targets: string[] = [] + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'agent/inbox/spliced') return + for (const message of event.data.inserted) { + insertedSources.push(message.source) + insertedShapes.push(Object.keys(message).sort()) + targets.push(event.data.target) + } }) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) - expect(queuedSources).toEqual([ + expect(insertedSources).toEqual([ { kind: 'user' }, { kind: 'plugin', plugin: 'goal' }, ]) - expect(queuedShapes).toEqual([ + expect(insertedShapes).toEqual([ ['content', 'id', 'role', 'source'], ['content', 'id', 'role', 'source'], ]) - expect(placements).toEqual(['queued', 'steering']) - // The drain appends the durable steering/message with the caller's source - // intact — the log, not a transient emit, is where consumers read it. - const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : []) + expect(targets).toEqual(['next-turn', 'next-step']) + const steeringSources = agent.session.events.flatMap(e => + e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) @@ -772,7 +551,7 @@ describe('turn numbering continues across seeded sessions', () => { describe('discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}', }) @@ -806,18 +585,24 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] + const errors: unknown[] = [] + ctx.on('agent/error', (_agent, turn, step, error) => { + expect({ turn, step }).toEqual({ turn: 1, step: 1 }) + errors.push(error) + }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, failure }]) + expect(reasons).toEqual([{ kind: 'error', error: failure }]) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(LlmError) + expect((errors[0] as LlmError).failure).toEqual(failure) const events = [...agent.session.events] - // The durable failure lives on turn/end.reason (with the failing step), not - // a standalone error event. const turnEnd = events.find(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure }) + expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) @@ -836,7 +621,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }]) + expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) }) @@ -854,7 +639,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }]) + expect(reasons).toEqual([{ kind: 'error', error: { message: 'codeless failure', code: 'UNKNOWN' } }]) }) }) @@ -944,8 +729,8 @@ describe('turn and step boundary recovery', () => { expect(stepEndIdx).toBeLessThan(turnEndIdx) }) - it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => { - const adapter = new MockAdapter([textResponse('after recovery')]) + it('a pre-commit turn/start rejection leaves no durable turn state', async () => { + const adapter = new MockAdapter([]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' }) let rejected = false @@ -965,23 +750,11 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) - // The rejected turn left nothing behind: no events, no admitted prompt. - expect(agent.session.events).toEqual([]) + expect(agent.session.events.some(event => event.type === 'turn/start' + || event.type === 'user/message')).toBe(false) + expect(agent.inbox.nextTurn).toHaveLength(1) expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit']) - - // The next prompt reuses the never-committed turn number and carries only - // its own admitted content — invariants (mounted) accept the log. - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 }) - const turnStart = agent.session.events.find(event => event.type === 'turn/start') - expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1) - const prompts = agent.session.events.filter(event => event.type === 'user/message') - expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([ - [{ type: 'text', text: 'go' }], - ]) - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(0) }) it('a pre-commit step/start validation failure does not invent a step boundary', async () => { @@ -997,11 +770,6 @@ describe('turn and step boundary recovery', () => { throw new Error('reject step-start before commit') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) - send(agent, 'go') await waitForIdle(ctx, agent) @@ -1013,10 +781,12 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) - expect(errors.map(error => error.message)).toEqual(['reject step-start before commit']) + expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, + }) }) - it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { + it('a step/end validation failure surfaces the resulting open-step invariant', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) @@ -1038,13 +808,16 @@ describe('turn and step boundary recovery', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - expect(errors.map(error => error.message)).toEqual(['reject first step-end']) + expect(errors.map(error => error.message)).toEqual([ + 'reject first step-end', + 'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open', + ]) expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, - turnEnd: 1, + turnEnd: 0, stepStart: 1, - stepEnd: 1, - errors: 1, + stepEnd: 0, + errors: 0, }) }) @@ -1068,9 +841,9 @@ describe('turn and step boundary recovery', () => { expect(c.stepStart).toBe(c.stepEnd) expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', - step: 1, - failure: { message: 'provider 500', code: 'SERVER' }, + error: { message: 'provider 500', code: 'SERVER' }, }) + expect(threw).toBe(true) // loop survives: a second turn runs to completion (invariants oracle would // throw on its turn/start if turn 1 had been left open). @@ -1106,13 +879,12 @@ describe('turn and step boundary recovery', () => { const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal - expect(reasons).toEqual([{ kind: 'disposed' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) // no error reason: disposal is not a failure. expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) }) - it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { - // Disposal remains authoritative when the listener also throws. + it('contains a pre-step throw after disposal inside a balanced no-step turn', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: Agent @@ -1121,8 +893,8 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/step', () => { - if (threw) return + ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { + if (threw) return next() threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') @@ -1136,13 +908,10 @@ describe('turn and step boundary recovery', () => { await agent.whenIdle() const e = [...agent.session.events] - // Balanced: one turn/start, one turn/end carrying disposed (NOT error). - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) - // No step opened (the throw was before step/start) and disposal is not a - // failure, so no agent/error for the contained throw. + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) + expect(e.find(x => x.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -1243,7 +1012,9 @@ describe('turn and step boundary recovery', () => { expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) expect(e.at(-1)?.type).toBe('turn/end') - expect(errors.map(error => error.message)).toEqual(['provider 500']) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(LlmError) + expect((errors[0] as LlmError).failure).toEqual({ message: 'provider 500', code: 'SERVER' }) // loop survives. send(agent, 'again') @@ -1327,7 +1098,7 @@ describe('tool result call identity', () => { }) describe('disposal and cancellation during pre-step assembly', () => { - it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { + it('disposal during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => { // Start disposal, then release assembly. Do not await disposal first: it // waits for the blocked driver to exit. const adapter = new MockAdapter(['hang']) @@ -1359,7 +1130,7 @@ describe('disposal and cancellation during pre-step assembly', () => { ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') - // Give the loop time to enter the step and reach assemble(). + // Give the loop time to reach pre-step assembly. await new Promise(r => setTimeout(r, 50)) // Release assembly before awaiting disposal because disposal joins the blocked driver. @@ -1370,17 +1141,16 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - // Turn boundaries are durable rows; there is no `agent/*` mirror to assert. const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) + expect(e.some(x => x.type === 'step/end')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) }) - it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { + it('cancel during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => { const adapter = new MockAdapter([textResponse('should not appear')]) let releaseAssemble!: () => void const blocker = new Promise<void>(r => void (releaseAssemble = r)) @@ -1419,20 +1189,18 @@ describe('disposal and cancellation during pre-step assembly', () => { unlisten() const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) + expect(e.some(x => x.type === 'step/end')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) }) - it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => { - // Start disposal, then release pre-step; awaiting disposal first would - // deadlock on the blocked driver. + it('disposal during pre-step closes a no-step turn', { timeout: 15000 }, async () => { + // Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise<void>(r => void (releasePreStep = r)) @@ -1447,8 +1215,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/step', async () => { + ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { await blocker + return next() }) let agent!: Agent @@ -1467,22 +1236,17 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - // After the agent/step listeners finish, the post-listener cancel/dispose check - // catches disposal. The step was never opened, no LLM call was made. + // The post-listener cancellation check catches disposal before any step or LLM call. const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - // Disposal wins the post-listener check — reason is `disposed`. - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // The durable turn/end record is the authoritative turn-boundary signal - // (turn boundaries have no agent/* mirror). + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) }) - it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => { - // Release agent/step after cancellation to exercise the post-listener check. + it('cancel during pre-step closes a no-step turn', { timeout: 15000 }, async () => { + // Release pre-step after cancellation to exercise the post-listener check. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise<void>(r => void (releasePreStep = r)) @@ -1497,8 +1261,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/step', async () => { + ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { await blocker + return next() }) let agent!: Agent @@ -1519,13 +1284,11 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) }) it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => { @@ -1565,14 +1328,12 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - // The critical assertions: after disposal, the turn has no assistant - // artifacts — the turn ended disposed before the model was invoked. + expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) + .toEqual(['turn/start', 'turn/end']) + expect(e.find(x => x.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - // The durable turn/end reason is the authoritative turn-boundary record - // (turn boundaries have no agent/* mirror). }) }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 8c31c4121e..273ef022fd 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -127,19 +127,14 @@ describe('thrown-value propagation', () => { await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(0) const starts = agent.session.events.filter(event => event.type === 'turn/start') const ends = agent.session.events.filter(event => event.type === 'turn/end') const messages = agent.session.events.filter(event => event.type === 'user/message') - expect(starts).toHaveLength(1) - // The rejected turn/start committed nothing, so the survivor reuses turn 1 - // and the rejected prompt does not leak into it. - expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1) - expect(ends).toHaveLength(1) - expect(messages).toHaveLength(1) - expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([ - { type: 'text', text: 'survives as the next item' }, - ]) + expect(starts).toHaveLength(0) + expect(ends).toHaveLength(0) + expect(messages).toHaveLength(0) + expect(agent.inbox.nextTurn).toHaveLength(2) }) it('preserves non-Error throws from the agent/request waterfall', async () => { @@ -156,22 +151,17 @@ describe('thrown-value propagation', () => { return next() }) - const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - send(agent, 'go') await waitForIdle(ctx, agent) - expect(errors).toHaveLength(1) - expect(errors[0]).toEqual({ code: 500 }) const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' - && ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)) - .toBeUndefined() + ? turnEnd.data.reason.error.message + : undefined).toBe('[object Object]') }) }) -describe('coded error data emission', () => { - it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { +describe('durable error rendering', () => { + it('renders a coded error thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -185,20 +175,16 @@ describe('coded error data emission', () => { return next() }) - const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - send(agent, 'go') await waitForIdle(ctx, agent) - expect(errors).toHaveLength(1) - expect(errorChain(errors[0])).toBe('server overloaded') - // turn-end error reason includes the code const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { - expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code) - .toBe('RATE_LIMIT') + expect(turnEnd.data.reason.error).toEqual({ + message: 'server overloaded', + code: 'RATE_LIMIT', + }) } }) }) @@ -221,7 +207,7 @@ describe('disposed vs aborted branching', () => { await driverDone(agent) // Disposal wins abort classification because the error path checks it first. - expect(reasons).toContainEqual({ kind: 'disposed' }) + expect(reasons).toContainEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) }) @@ -285,9 +271,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async ( - subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, signal, next, - ) => { + ctx.on('agent/request-error', async (subject, _context, signal, next) => { await next() subject.cancel({ kind: 'user' }) expect(signal.aborted).toBe(true) @@ -480,48 +464,44 @@ describe('unrenderable failure settlement', () => { if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { // The durable failure keeps the adapter facts' message, not the // unrenderable chain. - expect(end.data.reason.failure?.message).not.toBe('<unrenderable value>') + expect(errorChain(end.data.reason.error.message)).not.toBe('<unrenderable value>') } }) }) describe('driver bookkeeping edges', () => { - it('a deferred wake settles when replacement activity rejects', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), { - provider: 'mock', - model: 'mock', - }) - ctx.on('agent/inbox/enqueue', (subject) => { - if (subject !== agent) return - subject.cancel({ kind: 'user' }) - const mutable = subject as Agent & { done: Promise<void> } - mutable.done = Promise.reject(new Error('replacement rejected')) - }) + it('rejects a direct turn invocation without a driver reservation', async () => { + const ctx = await harness(new MockAdapter([])) + const agent = ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' }) - send(agent, 'cancel before wake') - - await expect(agent.whenIdle()).resolves.toBeUndefined() - expect(agent.session.events).toEqual([]) + await expect((agent as unknown as { turn(): Promise<boolean> }).turn()) + .rejects.toThrow('turn without driver reservation') + expect(agent.status).toBe('idle') }) - it('a whenIdle waiter survives a rejected driver promise', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('closes an entered turn as blocked when its next step is rejected', async () => { + const adapter = new MockAdapter([textResponse('first step')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' }) - // A throwing terminal-notification listener rejects the driver promise - // (the run's containment covers only session appends); the waiter's - // catch arm must treat that rejection as quiescence instead of - // propagating it. - ctx.on('agent/settled', (subject) => { - if (subject === agent) throw new Error('settled listener exploded') + const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) + let proposals = 0 + ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + proposals += 1 + return proposals === 2 ? { kind: 'reject' } : next() + }) + ctx.on('agent/turn-stopping', (subject) => { + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'do not enter the next step' }], + source: { kind: 'plugin', plugin: 'test' }, + })) }) - send(agent, 'one') - // Entered while the run owns the abort slot, the waiter awaits the - // driver promise; its rejection must count as quiescence and resolve. - await expect(agent.whenIdle()).resolves.toBeUndefined() + send(agent, 'go') + await agent.whenIdle() + + expect(proposals).toBe(2) + expect(adapter.requests).toHaveLength(1) + const end = agent.session.events.findLast(event => event.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index faa23a2657..86a3d664c5 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -11,8 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, - type InboxPlacement, - type PromptDecision, + type PreStepDecision, type SessionStartSource, } from '@deepseek-ai/dsh-agent' @@ -20,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** - * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`, + * The interception seams introduced by the hooks taxonomy: `agent/pre-step`, * `agent/session-start`, `agent/turn-stopping`, and the * `tools/pre-execute` / `tools/post-execute` * split with `additionalContexts` buffering. These verify the canonical event @@ -59,15 +58,15 @@ function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } -describe('agent/prompt-submit', () => { - it('allow (default via next) records the user/message unchanged', async () => { +describe('agent/pre-step', () => { + it('enter (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] - ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => { - seen.push(message.content.map(b => (b.type === 'text' ? b.text : '')).join('')) + ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -79,16 +78,44 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('reports the request coordinates for initial and tool-continuation prompts', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'hi' }), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineContentToolFixture({ + name: 'echo', + description: 'echo', + parameters: { text: { type: 'string', required: true } }, + execute: async ({ text }) => [{ type: 'text', text }], + })) + const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) + const seen: Array<{ turn: number; step: number; messages: number }> = [] + ctx.on('agent/pre-step', async (_agent, messages, context, next) => { + seen.push({ turn: context.turn, step: context.step, messages: messages.length }) + return next() + }) + + send(agent, 'hello') + await waitForIdle(ctx, agent) + + expect(seen).toEqual([ + { turn: 1, step: 1, messages: 1 }, + { turn: 1, step: 2, messages: 0 }, + ]) + }) + it('publishes frozen input without replacing its identity', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<PromptDecision>() + const decision = Promise.withResolvers<PreStepDecision>() const observed: UserMessage[] = [] - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject !== agent) return - const message = item.message + ctx.on('agent/pre-step', async (subject, messages) => { + if (subject !== agent) return { kind: 'enter', messages } + const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message.content)).toBe(true) expect(Object.isFrozen(message.content[0])).toBe(true) @@ -97,11 +124,7 @@ describe('agent/prompt-submit', () => { const block = message.content[0] if (block?.type === 'text') block.text = 'listener mutation' }).toThrow() - }) - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent) observed.push(item.message) - }) - ctx.on('agent/prompt-submit', async () => { + observed.push(message) entered.resolve(undefined) return decision.promise }) @@ -120,11 +143,11 @@ describe('agent/prompt-submit', () => { expect(() => { if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation' }).toThrow(TypeError) - decision.resolve({ kind: 'allow' }) + decision.resolve({ kind: 'enter', messages: [input] }) await idle expect(observed).toHaveLength(1) - expect(observed[0]).toBe(input) + expect(observed[0]).not.toBe(input) expect(observed[0]).toMatchObject({ content: [{ type: 'text', text: 'accepted text' }], source: { kind: 'plugin', plugin: 'accepted source' }, @@ -133,13 +156,16 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input) }) - it('allow with content REWRITES the prompt before it is recorded', async () => { + it('enter with content rewrites the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => - ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) + ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => + ({ + kind: 'enter', + messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], + })) send(agent, 'original') await waitForIdle(ctx, agent) @@ -151,15 +177,15 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => { + it('enter with additional messages records separately sourced context in the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => + ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => ({ - kind: 'allow', - additionalContexts: [createUserMessage({ + kind: 'enter', + messages: [...messages, createUserMessage({ content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }], source: { kind: 'plugin', plugin: 'test' }, })], @@ -178,41 +204,39 @@ describe('agent/prompt-submit', () => { expect(sent).toContain('extra ctx') }) - it('runs pre-step after prompt rewrites and injected context become durable', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('does not open another step when a completed turn rewrites pending input to empty', async () => { + const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => - ({ - kind: 'allow', - content: [{ type: 'text', text: 'REWRITTEN prompt' }], - additionalContexts: [createUserMessage({ - content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' }, - })], + const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/turn-stopping', (subject) => { + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'pending context' }], + source: { kind: 'plugin', plugin: 'test' }, })) - - let preStepDerived: string | undefined - ctx.on('agent/step', (subject, _turn, step) => { - if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) + }) + ctx.on('agent/pre-step', async (_subject, _messages, context, next) => { + const decision = await next() + return context.step === 1 || decision.kind === 'reject' + ? decision + : { kind: 'enter', messages: [] } }) - send(agent, 'ORIGINAL prompt') - await waitForIdle(ctx, agent) + send(agent, 'finish once') + await agent.whenIdle() - expect(preStepDerived).toBeDefined() - expect(preStepDerived).toContain('REWRITTEN prompt') - expect(preStepDerived).toContain('injected ctx') - expect(preStepDerived).not.toContain('ORIGINAL prompt') + expect(adapter.requests).toHaveLength(1) + expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1) }) - it('block drops the claimed prompt before any turn or model call', async () => { + it('reject closes the claimed prompt turn without a step or model call', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => - ({ kind: 'block', reason: 'blocked by policy' })) + ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' })) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -223,74 +247,81 @@ describe('agent/prompt-submit', () => { // the model was never called expect(adapter.requests).toHaveLength(0) const log = events(agent) - expect(log.some(e => e.type === 'turn/start')).toBe(false) - expect(log.some(e => e.type === 'turn/end')).toBe(false) + expect(log.filter(e => e.type === 'turn/start' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'turn/end']) expect(log.some(e => e.type === 'user/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) - expect(reasons).toEqual([]) + expect(reasons).toEqual([{ kind: 'blocked' }]) }) - it('stages inject and steer during admission for the admitted turn', async () => { + it('stages inject and steer during pre-step for the entered turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<PromptDecision>() - const placements: InboxPlacement[] = [] - ctx.on('agent/prompt-submit', async () => { + const decision = Promise.withResolvers<PreStepDecision>() + let claimed: UserMessage[] = [] + let firstProposal = true + ctx.on('agent/pre-step', async (_agent, messages) => { + if (!firstProposal) return { kind: 'enter', messages } + firstProposal = false + claimed = messages entered.resolve(undefined) return decision.promise }) - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject === agent) placements.push(item.placement) - }) const idle = waitForIdle(ctx, agent) - send(agent, 'admitted prompt') + send(agent, 'entered prompt') await entered.promise expect(agent.status).toBe('running') - expect(agent.acceptsNextStep).toBe(true) - expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + expect(events(agent).some(event => event.type === 'turn/start')).toBe(true) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'attached context' }], source: { kind: 'plugin', plugin: 'test' }, })) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } })) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } })) expect(events(agent).some(event => event.type === 'user/message')).toBe(false) - expect(placements).toEqual(['queued', 'steering']) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'attached context' }, + { type: 'text', text: 'pre-step steering' }, + ]) - decision.resolve({ kind: 'allow' }) + decision.resolve({ kind: 'enter', messages: claimed }) await idle - expect(agent.acceptsNextStep).toBe(false) + expect(agent.inbox.hasPending).toBe(false) const staged = events(agent).filter(event => - event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message') + event.type === 'turn/start' || event.type === 'user/message') expect(staged.map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'user/message', - 'steering/message', + 'user/message', ]) expect(staged[1]?.type === 'user/message' && staged[1].data.content) - .toEqual([{ type: 'text', text: 'admitted prompt' }]) + .toEqual([{ type: 'text', text: 'entered prompt' }]) expect(staged[2]?.type === 'user/message' && staged[2].data.content) .toEqual([{ type: 'text', text: 'attached context' }]) - expect(staged[3]?.type === 'steering/message' && staged[3].data.message.content) - .toEqual([{ type: 'text', text: 'admission steering' }]) - const request = JSON.stringify(adapter.requests[0]?.messages) - expect(request).toContain('admitted prompt') - expect(request).toContain('attached context') - expect(request).toContain('admission steering') + expect(staged[3]?.type === 'user/message' && staged[3].data.content) + .toEqual([{ type: 'text', text: 'pre-step steering' }]) + const firstRequest = JSON.stringify(adapter.requests[0]?.messages) + expect(firstRequest).toContain('entered prompt') + expect(firstRequest).not.toContain('attached context') + expect(firstRequest).not.toContain('pre-step steering') + const nextRequest = JSON.stringify(adapter.requests[1]?.messages) + expect(nextRequest).toContain('attached context') + expect(nextRequest).toContain('pre-step steering') }) - it('keeps admission-time outbox input staged when admission is blocked', async () => { + it('preserves input staged after the blocked batch was claimed', async () => { const adapter = new MockAdapter([textResponse('retried')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<PromptDecision>() - const disposeBlock = ctx.on('agent/prompt-submit', async () => { + const decision = Promise.withResolvers<PreStepDecision>() + const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) @@ -298,17 +329,21 @@ describe('agent/prompt-submit', () => { const blockedIdle = waitForIdle(ctx, agent) send(agent, 'blocked prompt') await entered.promise - expect(agent.acceptsNextStep).toBe(true) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'staged context' }], source: { kind: 'plugin', plugin: 'test' }, })) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } })) - decision.resolve({ kind: 'block', reason: 'policy' }) + decision.resolve({ kind: 'reject' }) await blockedIdle - expect(agent.acceptsNextStep).toBe(false) - expect(events(agent)).toEqual([]) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'staged context' }, + { type: 'text', text: 'staged steering' }, + ]) + expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end') + .map(event => event.type)).toEqual(['turn/start', 'turn/end']) expect(adapter.requests).toEqual([]) disposeBlock() @@ -316,10 +351,10 @@ describe('agent/prompt-submit', () => { await waitForIdle(ctx, agent) const staged = events(agent).filter(event => - event.type === 'user/message' || event.type === 'steering/message') + event.type === 'user/message') expect(staged.map(event => event.type)).toEqual([ 'user/message', - 'steering/message', + 'user/message', 'user/message', ]) expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') @@ -327,21 +362,26 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') }) - it('orders rejected-admission outbox input before a later admitted prompt', async () => { - const adapter = new MockAdapter([textResponse('continued')]) + it('preserves later queued work when a step is rejected', async () => { + const adapter = new MockAdapter([ + textResponse('continued'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), { + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), { provider: 'mock', model: 'mock', }) - ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => { + ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { const decision = await next() - return message.content.some(block => block.type === 'text' && block.text === 'blocked prompt') - ? { kind: 'block', reason: 'policy' } + return messages.some(message => + message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) + ? { kind: 'reject' as const } : decision }) - ctx.on('agent/prompt-submit', async (subject, message, _signal, next) => { - if (message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) { + ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { + if (messages.some(message => + message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'earlier state change' }], source: { kind: 'plugin', plugin: 'test' }, @@ -359,29 +399,34 @@ describe('agent/prompt-submit', () => { send(agent, 'later prompt') await idle - const staged = events(agent).filter(event => - event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message') - expect(staged.map(event => event.type)).toEqual([ - 'turn/start', - 'user/message', - 'steering/message', - 'user/message', - ]) - expect(staged[1]?.type === 'user/message' && staged[1].data.content) - .toEqual([{ type: 'text', text: 'earlier state change' }]) - expect(staged[2]?.type === 'steering/message' && staged[2].data.message.content) - .toEqual([{ type: 'text', text: 'earlier steering' }]) - expect(staged[3]?.type === 'user/message' && staged[3].data.content) + expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end') + .map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'earlier state change' }, + { type: 'text', text: 'earlier steering' }, + ]) + expect(agent.inbox.nextTurn.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'later prompt' }]) + expect(adapter.requests).toEqual([]) + + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('earlier state change') + expect(request).toContain('earlier steering') + expect(request).toContain('later prompt') + expect(request).not.toContain('blocked prompt') }) - it('commits context-only injection when admission closes without a turn', async () => { - const adapter = new MockAdapter([]) + it('preserves context-only injection staged after pre-step began', async () => { + const adapter = new MockAdapter([textResponse('continued')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<PromptDecision>() - ctx.on('agent/prompt-submit', async () => { + const decision = Promise.withResolvers<PreStepDecision>() + const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) @@ -393,91 +438,90 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'independent context' }], source: { kind: 'plugin', plugin: 'test' }, })) - decision.resolve({ kind: 'block', reason: 'policy' }) + decision.resolve({ kind: 'reject' }) await idle const log = events(agent) - expect(log.map(event => event.type)).toEqual(['user/message']) - expect(log[0]?.type === 'user/message' && log[0].data.content) + expect(log.some(event => event.type === 'user/message')).toBe(false) + expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'independent context' }]) expect(adapter.requests).toEqual([]) + + disposeBlock() + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('independent context') + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') }) - it('retains rejected-admission context when its idle append fails', async () => { - const adapter = new MockAdapter([textResponse('retried')]) + it('leaves inbox state unchanged when its durable append fails', async () => { + const adapter = new MockAdapter([]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), { + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), { provider: 'mock', model: 'mock', }) - const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) vi.spyOn(agent.session, 'append').mockImplementationOnce(() => { throw new Error('append unavailable') }) - const entered = Promise.withResolvers<undefined>() - const decision = Promise.withResolvers<PromptDecision>() - const disposeBlock = ctx.on('agent/prompt-submit', async () => { - entered.resolve(undefined) - return decision.promise - }) - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } })) - await entered.promise - agent.inject(createUserMessage({ - content: [{ type: 'text', text: 'retained context' }], - source: { kind: 'plugin', plugin: 'test' }, - })) - decision.resolve({ kind: 'block', reason: 'policy' }) - await agent.whenIdle() + expect(() => { + send(agent, 'blocked prompt') + }).toThrow('append unavailable') expect(events(agent)).toEqual([]) - expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable')) - - disposeBlock() - send(agent, 'resume') - await waitForIdle(ctx, agent) - - expect(events(agent).some(event => event.type === 'user/message' - && JSON.stringify(event.data.content).includes('retained context'))).toBe(true) + expect(agent.inbox.hasPending).toBe(false) + expect(agent.status).toBe('idle') }) - it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => { - const adapter = new MockAdapter([textResponse('ran once')]) + it('a blocked prompt preserves adjacent queued prompts', async () => { + const adapter = new MockAdapter([ + textResponse('safe reply'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => { - const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('') - return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next() + ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { + const text = messages.flatMap(message => message.content) + .map(b => (b.type === 'text' ? b.text : '')).join('') + return text === 'secret' + ? { kind: 'reject' } + : next() }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - // The rejected admission is dropped; the allowed prompt owns the only turn. send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) const log = events(agent) - // The allowed prompt became a user/message and drove exactly one model call. - const userMsgs = log.filter(e => e.type === 'user/message') - expect(userMsgs).toHaveLength(1) - expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) - expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + expect(log.filter(e => e.type === 'user/message')).toHaveLength(0) + expect(adapter.requests).toHaveLength(0) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(reasons).toEqual([{ kind: 'completed' }]) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(reasons).toEqual([{ kind: 'blocked' }]) + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'safe' }]) + + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('safe') + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret') }) - it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => { + it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false - ctx.on('agent/prompt-submit', async () => { + ctx.on('agent/pre-step', async (_agent, messages) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } - return { kind: 'allow' as const } + return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -494,14 +538,18 @@ describe('agent/prompt-submit', () => { send(agent, 'first') send(agent, 'second') await idle - expect(errors).toEqual([]) + expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })]) const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) - expect(reasons).toEqual([{ kind: 'completed' }]) + expect(reasons).toEqual([{ + kind: 'error', + error: { message: 'prompt hook broke', code: 'UNKNOWN' }, + }]) expect(statuses).toEqual(['running', 'idle']) - expect(adapter.requests).toHaveLength(1) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') + expect(adapter.requests).toHaveLength(0) + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'second' }]) }) }) @@ -679,10 +727,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ctx.on('agent/session-start', (agent, source) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) - // 2. PromptSubmit: block a forbidden prompt, annotate the rest. - ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => { - const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('') - if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' } + // 2. PreStep: reject a forbidden prompt, annotate the rest. + ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { + const text = messages.flatMap(message => message.content) + .map(b => (b.type === 'text' ? b.text : '')).join('') + if (text.includes('rm -rf')) { + return { kind: 'reject' } + } return next() }) // 3. PreToolUse: deny a dangerous tool by name. @@ -730,7 +781,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) }) - it('the same plugin blocks a destructive prompt before a turn or model call', async () => { + it('the same plugin blocks a destructive prompt inside a no-step turn', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) @@ -743,7 +794,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await agent.whenIdle() expect(adapter.requests).toHaveLength(0) - expect(reasons).toEqual([]) + expect(reasons).toEqual([{ kind: 'blocked' }]) }) it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => { @@ -756,7 +807,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) - // the prompt ran (not rejected) — proving the prompt-submit listener was disposed + // the prompt ran (not rejected) — proving the pre-step listener was disposed expect(adapter.requests).toHaveLength(1) expect(events(agent).some(e => e.type === 'user/message')).toBe(true) }) diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index d3381cd524..d77ad3a7d6 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> { async function requestSetup() { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -42,12 +42,16 @@ describe('request-reconstruction invariant', () => { expect(() => { dispatch(ctx, options) }).not.toThrow() }) - it('uses the step boundary rather than content appended afterward', async () => { - const { ctx, session, boundary } = await requestSetup() + it('includes context appended inside the open step before dispatch', async () => { + const { ctx, session } = await requestSetup() session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' }, + content: [{ type: 'text', text: '[step context]' }], source: { kind: 'plugin', plugin: 'x' }, }), { surfaceOp: 'append' }) - const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + const options = loopRequest({ + model: 'm', + messages: Object.freeze(session.deriveMessages()), + sessionId: session.id, + }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) @@ -57,16 +61,16 @@ describe('request-reconstruction invariant', () => { expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) .not.toThrow() expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) }) - .toThrow(/diverges from the boundary derivation/) + .toThrow(/diverges from the dispatch-time durable derivation/) expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) }) - .toThrow(/diverges from the boundary derivation/) + .toThrow(/diverges from the dispatch-time durable derivation/) }) it('rejects message and header divergence', async () => { const { ctx, session, boundary } = await requestSetup() const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) - .toThrow(/diverges from the boundary derivation/) + .toThrow(/diverges from the dispatch-time durable derivation/) expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) .toThrow(/diverges from the folded request header/) }) @@ -74,7 +78,7 @@ describe('request-reconstruction invariant', () => { it('rejects loop requests with no boundary or header', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-bare')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) session.append('step/start', { turn: 1, step: 1 }) @@ -122,7 +126,7 @@ describe('request-reconstruction invariant', () => { await ctx.plugin(InvariantService) await ctx.plugin(AgentLoopInvariant) const session = ctx.sessions.create(SessionId('prepend-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -133,6 +137,6 @@ describe('request-reconstruction invariant', () => { messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), sessionId: session.id, }) - expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the dispatch-time durable derivation/) }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 15bfa7e0fc..94e39a08a2 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -25,11 +25,7 @@ async function harness(adapter: MockAdapter, persona = '') { return ctx } -/** - * Wait for the agent's NEXT transition to idle. Always event-based: callers - * invoke this right after send(), when the loop hasn't woken yet (status is - * still 'idle' synchronously), so polling the current status would lie. - */ +/** Wait for the agent's next transition to idle after a waking send. */ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -73,6 +69,35 @@ describe('agent loop', () => { expect(adapter.requests[0]?.maxTokens).toBe(256) }) + it('cancels queued wakeup work together with an active maintenance task', async () => { + const adapter = new MockAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers<undefined>() + const maintenance = agent.runMaintenance(async (signal) => { + started.resolve(undefined) + await new Promise<void>((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('maintenance aborted', { cause: signal.reason })) + }, { once: true }) + }) + }) + await started.promise + + send(agent, 'discard this wakeup') + agent.cancel({ kind: 'user' }) + send(agent, 'park after cancellation') + + await expect(maintenance).rejects.toThrow('maintenance aborted') + await agent.whenIdle() + expect(agent.inbox.nextTurn).toHaveLength(1) + expect(adapter.requests).toEqual([]) + agent.cancel({ kind: 'user' }) + }) + it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) @@ -94,11 +119,10 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) const types = agent.session.events.map(e => e.type) - // turn/start opens the turn, THEN the queued user message is recorded inside - // it (every event is turn-enclosed), then the assembled message (carrying the - // step's usage). - expect(types[0]).toBe('turn/start') - expect(types[1]).toBe('user/message') + // Durable inbox receipt precedes the turn-owned transcript. + expect(types[0]).toBe('agent/inbox/spliced') + expect(types).toContain('turn/start') + expect(types).toContain('user/message') expect(types).toContain('assistant/message') const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length }) @@ -201,9 +225,14 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) // the request was never sent - expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true) + expect(errors.map(error => error.message)).toEqual([ + 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")', + ]) const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' + ? turnEnd.data.reason.error.message + : '').toContain('no value for this assembly') // The loop survived: a waterfall listener rescues {{cwd}} and the SAME // agent completes a real model turn. @@ -470,10 +499,7 @@ describe('agent loop', () => { parameters: {}, async execute() { // steer while the turn is running (during tool execution) - agent.send( - createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }), - { target: 'next-step', wakeup: true }, - ) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })) return [{ type: 'text', text: 'tool done' }] }, })) @@ -481,13 +507,15 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) - const types = agent.session.events.map(e => e.type) - expect(types).toContain('steering/message') - // steering recorded before the second step's request derived its history - const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq + const steering = agent.session.events.find(e => + e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) + expect(steering).toBeDefined() + // The entered batch is appended after the second step opens and before its + // request derives history. + const steeringSeq = steering!.seq const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() - expect(steeringSeq).toBeLessThan(secondStepStart!.seq) + expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) // the second model request saw the steering content const secondRequest = adapter.requests[1] @@ -495,40 +523,41 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('same-tick idle steering preserves one turn per send', async () => { + it('starts idle steering synchronously and enters later steering at the next step', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })) + expect(agent.status).toBe('running') + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })) await idle - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(agent.session.events .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'first idle steer' }], [{ type: 'text', text: 'second idle steer' }], ]) - expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([]) expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer') expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('second idle steer') expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer') }) - it('keeps steering staged after a failed step until the next admitted turn', async () => { + it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => { const adapter = new MockAdapter([textResponse('recovered')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true - ctx.on('agent/step', (subject) => { - if (subject !== agent || !fail) return + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + if (subject !== agent || !fail) return next() fail = false subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) - throw new Error('step failed') + throw new Error('pre-step failed') }) send(agent, 'prompt') @@ -536,132 +565,18 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(0) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1) + expect(agent.inbox.nextStep).toHaveLength(1) send(agent, 'resume') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) - it('rejects failed steering commits while preserving later context', async () => { - const adapter = new MockAdapter([textResponse('recovered')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' }) - let receipt: ReturnType<Agent['steer']> | undefined - ctx.on('agent/step', (subject) => { - if (subject !== agent || receipt !== undefined) return - receipt = subject.steer(createUserMessage({ - content: [{ type: 'text', text: 'rejected steering' }], - source: { kind: 'user' }, - })) - subject.inject(createUserMessage({ - content: [{ type: 'text', text: 'preserved context' }], - source: { kind: 'plugin', plugin: 'loop-test' }, - })) - }) - let rejected = false - ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as { type: string } - if (event.type === 'steering/message' && !rejected) { - rejected = true - throw new Error('reject steering commit') - } - }) - - send(agent, 'first prompt') - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(0) - if (receipt === undefined) throw new Error('agent/step did not submit steering') - expect(await receipt.outcome).toEqual({ status: 'rejected' }) - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) - - send(agent, 'recover') - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(1) - const request = JSON.stringify(adapter.requests[0]?.messages) - expect(request).toContain('preserved context') - expect(request).not.toContain('rejected steering') - }) - - it('rejects committed steering when the step boundary fails', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' }) - let receipt: ReturnType<Agent['steer']> | undefined - ctx.on('agent/step', (subject) => { - if (subject !== agent || receipt !== undefined) return - receipt = subject.steer(createUserMessage({ - content: [{ type: 'text', text: 'committed steering' }], - source: { kind: 'user' }, - })) - }) - ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as { type: string } - if (event.type === 'step/start') throw new Error('reject step boundary') - }) - - send(agent, 'prompt') - await waitForIdle(ctx, agent) - - if (receipt === undefined) throw new Error('agent/step did not submit steering') - expect(await receipt.outcome).toEqual({ status: 'rejected' }) - expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) - }) - - it('retries context and steering after a context commit fails', async () => { - const adapter = new MockAdapter([textResponse('recovered')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' }) - let receipt: ReturnType<Agent['steer']> | undefined - ctx.on('agent/step', (subject) => { - if (subject !== agent || receipt !== undefined) return - subject.inject(createUserMessage({ - content: [{ type: 'text', text: 'preserved context' }], - source: { kind: 'plugin', plugin: 'loop-test' }, - })) - receipt = subject.steer(createUserMessage({ - content: [{ type: 'text', text: 'preserved steering' }], - source: { kind: 'user' }, - })) - }) - let rejected = false - ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as { type: string; data?: { source?: { kind: string } } } - if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) { - rejected = true - throw new Error('reject context commit') - } - }) - - send(agent, 'first prompt') - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) - - send(agent, 'recover') - await waitForIdle(ctx, agent) - - if (receipt === undefined) throw new Error('agent/step did not submit steering') - expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 }) - expect(adapter.requests).toHaveLength(1) - const request = JSON.stringify(adapter.requests[0]?.messages) - expect(request).toContain('preserved context') - expect(request).toContain('preserved steering') - }) - - it('inject() while idle appends context without opening a turn', async () => { + it('inject() while idle durably stages context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -671,11 +586,14 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(0) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) expect(agent.session.events.at(-1)).toMatchObject({ - type: 'user/message', + type: 'agent/inbox/spliced', data: { - role: 'user', - content: [{ type: 'text', text: 'file changed: a.ts' }], - source: { kind: 'plugin', plugin: 'watcher' }, + target: 'next-step', + inserted: [{ + role: 'user', + content: [{ type: 'text', text: 'file changed: a.ts' }], + source: { kind: 'plugin', plugin: 'watcher' }, + }], }, }) @@ -736,8 +654,6 @@ describe('agent loop', () => { // split the assistant tool call from the provider's tool-result message. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - const ts0 = turnStarts[0]! - expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') const result = agent.session.events.find(e => e.type === 'tool/result')! const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) @@ -831,31 +747,20 @@ describe('agent loop', () => { expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) }) - it('a concluding tool result beats steering that arrived during the same step', async () => { + it('continues for steering that arrived during a concluding tool step', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'finalize', {}), textResponse('next turn reply'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let receipt: ReturnType<Agent['steer']> | undefined - let contextInjected = false - ctx.on('session/event', (session, event) => { - if (session !== agent.session || event.type !== 'step/end' || contextInjected) return - contextInjected = true - agent.inject(createUserMessage({ - content: [{ type: 'text', text: 'final context' }], - source: { kind: 'plugin', plugin: 'finalize' }, - })) - }) ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { - // Steering lands while the concluding tool is still executing; the - // step/end listener adds ordinary context after the normal result drain. - receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) + // Steering lands while the concluding tool is still executing. + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] }, @@ -864,26 +769,15 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // The terminal result stands: no extra request reopens the concluded turn. - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(2) const events = agent.session.events.map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) - if (receipt === undefined) throw new Error('concluding tool did not submit steering') - expect(await receipt.outcome).toEqual({ status: 'rejected' }) - expect(events).not.toContain('steering/message') - expect(agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true) - - send(agent, 'follow up') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering') const texts = adapter.requests[1]!.messages .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) - expect(texts).toContain('final context') - expect(texts).not.toContain('late steering') + expect(texts).toContain('late steering') }) it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { @@ -909,7 +803,7 @@ describe('agent loop', () => { expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) - it('agent/step fires once per step before the step is opened', async () => { + it('agent/pre-step fires once per proposed step before the step is opened', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -922,8 +816,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] - ctx.on('agent/step', (subject, turn, step, signal) => { + ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { if (subject === agent) fires.push({ turn, step, signal }) + return next() }) send(agent, 'go') @@ -936,50 +831,33 @@ describe('agent loop', () => { expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true) }) - it('agent/step fires BEFORE the step it precedes opens (events land outside the step)', async () => { - // The append lands before step/start, yet derive happens afterwards and the - // same step's request must include it. + it('agent/pre-step fires before its step boundary opens and before the request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let injected = false - ctx.on('agent/step', (subject) => { - if (subject === agent && !injected) { - injected = true - subject.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], - source: { kind: 'plugin', plugin: 'test' }, - }), { surfaceOp: 'append' }) - } + let boundaryOpen = true + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' + return next() }) send(agent, 'go') await waitForIdle(ctx, agent) - // The adapter's request includes the node injected during pre-step (derive - // reflects it). - const text = JSON.stringify(adapter.requests[0]!.messages) - expect(text).toContain('INJECTED-IN-PRE-STEP') - - // And the injected event sits BEFORE the first step/start in the log — - // the seam fired outside the step. - const events = agent.session.events - const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq - const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq - expect(injectedSeq).toBeLessThan(firstStepStartSeq) + expect(boundaryOpen).toBe(false) + expect(adapter.requests).toHaveLength(1) }) - it('a throwing agent/step listener ends the turn (error), not the loop', async () => { - // Before step/start, a pre-step throw reaches the turn catch: no step needs - // closing, the turn records error, and the loop remains available. + it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => { const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true - ctx.on('agent/step', () => { + ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } + return next() }) const errors: Error[] = [] @@ -989,16 +867,11 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // The first turn failed at step 1 (no model call happened), surfaced via - // agent/error, with the durable failure on turn/end.reason. - expect(errors).toHaveLength(1) - expect(errors[0]!.message).toContain('boom in pre-step') + // The first proposal failed inside a balanced turn without calling the model. + expect(errors.map(error => error.message)).toEqual(['boom in pre-step']) expect(adapter.requests.length).toBe(0) - const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 }) - // The step opened-and-closed count stays balanced even though it never ran. - const types = agent.session.events.map(e => e.type) - expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true) // The loop survived: a second prompt runs a normal completed turn. send(agent, 'second') @@ -1023,7 +896,7 @@ describe('agent loop', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'aborted' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) }) it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => { @@ -1094,6 +967,8 @@ describe('agent loop', () => { source: { kind: 'plugin', plugin: 'max-tokens-test' }, }, ]) + // A max-token step is sticky: the later completed step must not + // downgrade the turn outcome. expect(reasons).toEqual([{ kind: 'max-tokens' }]) }) @@ -1311,14 +1186,15 @@ describe('agent loop', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) - it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => { - const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + it('contains a reentrant send attempted during durable inbox publication', async () => { + const adapter = new MockAdapter([textResponse('first')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let nested = false - ctx.on('agent/inbox/enqueue', (subject) => { - if (subject !== agent || nested) return + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'agent/inbox/spliced' + || event.data.inserted.length === 0 || nested) return nested = true send(agent, 'queued listener message') }) @@ -1331,11 +1207,8 @@ describe('agent loop', () => { const messages = agent.session.events .filter(event => event.type === 'user/message') .map(event => event.data.content) - expect(turns).toHaveLength(2) - expect(messages).toEqual([ - [{ type: 'text', text: 'outer message' }], - [{ type: 'text', text: 'queued listener message' }], - ]) + expect(turns).toHaveLength(1) + expect(messages).toEqual([[{ type: 'text', text: 'outer message' }]]) }) it('preserves independent turn sources across an adjacent microtask send', async () => { @@ -1349,16 +1222,11 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - const triggers = agent.session.events - .filter(event => event.type === 'turn/start') - .map(event => event.data.trigger) + const turns = agent.session.events.filter(event => event.type === 'turn/start') const sources = agent.session.events .filter(event => event.type === 'user/message') .map(event => event.data.source) - expect(triggers).toEqual([ - { kind: 'message', source: { kind: 'user' } }, - { kind: 'message', source: { kind: 'plugin', plugin: 'test' } }, - ]) + expect(turns).toHaveLength(2) expect(sources).toEqual([ { kind: 'user' }, { kind: 'plugin', plugin: 'test' }, @@ -1379,7 +1247,7 @@ describe('agent loop', () => { ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk' && !queued) { queued = true - send(agent, 'second message') + queueMicrotask(() => { send(agent, 'second message') }) } }) @@ -1421,15 +1289,15 @@ describe('agent loop', () => { ]) }) - it('errors from the model surface as agent/error and end the turn', async () => { + it('records normalized model errors on the turn boundary', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const errors: Error[] = [] + const errors: unknown[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) + errors.push(error) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1437,12 +1305,15 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) - expect(errors[0]!.message).toContain('script exhausted') + expect(errors[0]).toBeInstanceOf(LlmError) + expect((errors[0] as LlmError).failure).toEqual({ + message: 'MockAdapter: script exhausted', + code: 'UNKNOWN', + }) expect(reasons[0]).toMatchObject({ kind: 'error' }) - // The durable failure lives entirely on turn/end.reason (with the failing - // step), not a standalone error event. + // The durable failure and live relay describe the same failed turn. const turnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => { diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index a75baf9203..c5dec8c142 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -78,7 +78,7 @@ function userMessageTexts(agent: Agent): string[] { function turnNumbers(agent: Agent): number[] { return agent.session.events .filter(e => e.type === 'turn/start') - .map(e => (e.data as { turn: number }).turn) + .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index c0e151016d..96b6bfc045 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -59,26 +59,15 @@ describe('agent/request-error', () => { turn: number step: number failure: LlmFailure - priorFailures: readonly LlmFailure[] retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] - const settledTurns: number[] = [] ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) - ctx.on('agent/settled', (subject, turn) => { - if (subject === agent) settledTurns.push(turn) - }) - ctx.on('agent/request-error', async ( - subject, turn, step, _error, failure, priorFailures, retryPolicy, - ) => { + ctx.on('agent/request-error', async (subject, context) => { expect(subject).toBe(agent) - expect(agent.session.events.at(-1)).toMatchObject({ - type: 'step/end', - data: { turn, step }, - }) - seen.push({ turn, step, failure, priorFailures, retryPolicy }) + seen.push(context) return { kind: 'retry' } }) @@ -96,25 +85,17 @@ describe('agent/request-error', () => { code: 'RATE_LIMIT', }, { - turn: 2, + turn: 1, step: 1, code: 'SERVICE_UNAVAILABLE', }, ]) - expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger)) - .toEqual([ - { kind: 'message', source: { kind: 'user' } }, - { kind: 'retry' }, - { kind: 'retry' }, - ]) - expect(seen.map(item => item.priorFailures.map(failure => failure.code))) - .toEqual([[], ['RATE_LIMIT']]) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(seen.map(item => item.retryPolicy)).toEqual([ expect.objectContaining({ mode: 'normal' }), expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) - expect(settledTurns).toEqual([3]) }) it('lets cancellation win over a retry action', async () => { @@ -133,7 +114,7 @@ describe('agent/request-error', () => { expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'aborted' } }, + data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 38398a9c2e..565bb73f63 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -350,10 +350,6 @@ describe('request stability across the loop', () => { } }([]) const ctx = await harness(adapter) - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), { provider: 'mock', model: 'mock', @@ -362,7 +358,13 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(errors).toContain(failure) + expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { + reason: failure instanceof LlmError + ? { kind: 'error', error: failure.failure } + : { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } }, + }, + }) expect(adapter.requests).toHaveLength(0) }, ) @@ -409,19 +411,13 @@ describe('request stability across the loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // A pre-step listener compacts turn 1's history before turn 2's step — - // the sanctioned surface rewrite, landing OUTSIDE the step. - const preStep = ctx.on('agent/step', () => { - preStep() - const session = agent.session - const nodes = session.surface.nodes - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '[summary of turn 1]' }], - source: { kind: 'plugin', plugin: 'test-compact' }, - }), { - surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, - sourceEventSeqs: [nodes[0]!, nodes[1]!], - }) + const nodes = agent.session.surface.nodes + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '[summary of turn 1]' }], + source: { kind: 'plugin', plugin: 'test-compact' }, + }), { + surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, + sourceEventSeqs: [nodes[0]!, nodes[1]!], }) send(agent, 'second') @@ -491,10 +487,6 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) ctx.on('llm/stream', (options, next) => { // The historical failure mode this design kills: a listener rewriting // request content in place. The freeze turns it into a loud error. @@ -508,8 +500,10 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(errors).toHaveLength(1) - expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i) + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } }) + if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error() + expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i) }) it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { @@ -595,14 +589,18 @@ describe('request stability across the loop', () => { adapter.requests.forEach((request, index) => { const stepStart = stepStarts[index]! - // Messages: the derivation over the log prefix strictly before this - // step's step/start — rebuilt here through a completely fresh Session. - const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq))) + const firstChunk = events.find(e => + e.type === 'assistant/chunk' + && e.data.turn === stepStart.data.turn + && e.data.step === stepStart.data.step, + )! + // Messages: the entered batch is logged after step/start, so rebuild the + // complete dispatch prefix through a completely fresh Session. + const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq))) expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages()) // Header: the latest request/header snapshot up to this step's dispatch // (its header event sits between step/start and the first chunk). - const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! const header = foldRequestHeader(events.slice(0, firstChunk.seq))! expect(request.model).toBe(header.config.model) expect(request.reasoningEffort).toBe(header.config.reasoningEffort) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 0bb7ce7d2e..62f9e9059e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,12 +1,12 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> { // balanced completed turn is the smallest resumable log and avoids running // the model merely to construct this lifecycle fixture. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const session = ctx.sessions.create(sessionId, { seed }) @@ -52,6 +52,18 @@ async function persistSession(sessionId: SessionId): Promise<string> { return root } +/** Build a detached preparation for lifecycle-race test doubles. */ +function preparationFromSnapshot( + ctx: Context, + snapshot: { meta: SessionHeader; events: readonly SessionEvent[] }, +): SessionPreparation { + return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, { + seed: structuredClone(snapshot.events) as SessionEvent[], + meta: structuredClone(snapshot.meta), + seedSource: 'persistence', + })) +} + function waitForIdle(ctx: Context, agent: Agent): Promise<void> { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -77,7 +89,7 @@ function throwUnknown(value: unknown): never { } describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => { - it('resumes a session persisted before messages gained identities', async () => { + it('resumes a pre-react-loop session including pre-identity message events', async () => { const sessionId = SessionId('pre-identity-resume') const first = await persistentHarness(new MockAdapter([])) await first.ctx.sessionPersistence.create({ @@ -86,7 +98,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', createdAt: 1, }) await first.ctx.sessionPersistence.append(sessionId, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'turn/start', seq: 0, time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, { type: 'user/message', seq: 1, @@ -107,8 +122,19 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', }, surfaceOp: 'append', }, - { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + { + type: 'steering/message', + seq: 4, + time: 5, + data: { + turn: 1, + content: [{ type: 'text', text: 'old steering' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, ] as unknown as SessionEvent[]) await first.ctx.fiber.dispose() @@ -120,14 +146,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(handle.agent.session.deriveMessages()).toMatchObject([ { id: `legacy-message:${sessionId}:1`, role: 'user' }, { id: `legacy-message:${sessionId}:3`, role: 'assistant' }, + { id: `legacy-message:${sessionId}:4`, role: 'user' }, ]) + expect(handle.agent.inbox.nextTurn).toEqual([]) + expect(handle.agent.inbox.nextStep).toEqual([]) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'new question' }], source: { kind: 'user' }, })) await waitForIdle(ctx, handle.agent) - expect(handle.agent.session.deriveMessages()).toHaveLength(4) + expect(handle.agent.session.deriveMessages()).toHaveLength(5) expect(handle.agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -175,11 +204,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')])) const sessionId = SessionId('live-resume-race') const first = (await ctx.agents.create({ sessionId })).agent - first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.session.append('turn/start', { turn: 1 }) await ctx.sessions.flush(first.session) await expect(ctx.agents.resume({ resumeSessionId: sessionId })) - .rejects.toThrow(/live turn is open/) + .rejects.toThrow(/while it is live/) first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(first.session) @@ -429,22 +458,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence preparation, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) - const lateLoad = Promise.withResolvers<typeof snapshot>() - const loadStarted = Promise.withResolvers<undefined>() - let loads = 0 - ctx.sessionPersistence.load = (id) => { + const abandoned = preparationFromSnapshot(ctx, snapshot) + const latePreparation = Promise.withResolvers<SessionPreparation>() + const preparationStarted = Promise.withResolvers<undefined>() + const originalPrepare = ctx.sessionPersistence.prepare.bind(ctx.sessionPersistence) + let preparations = 0 + ctx.sessionPersistence.prepare = (id, signal) => { expect(id).toBe(sessionId) - loads += 1 - if (loads === 1) { - loadStarted.resolve(undefined) - return lateLoad.promise + preparations += 1 + if (preparations === 1) { + preparationStarted.resolve(undefined) + return latePreparation.promise } - return Promise.resolve(structuredClone(snapshot)) + return originalPrepare(id, signal) } const published: string[] = [] @@ -456,7 +487,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) - await loadStarted.promise + await preparationStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) @@ -468,23 +499,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // can be reused before awaiting the public rejection. const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) await rejection - expect(loads).toBe(2) + expect(preparations).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) // Settlement of the abandoned backend promise cannot resume the old // transaction or emit a second publication after the retry owns the ids. - lateLoad.resolve(structuredClone(snapshot)) + latePreparation.resolve(abandoned) await Promise.resolve() await Promise.resolve() expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) + abandoned[Symbol.dispose]() await retry.dispose() await ctx.fiber.dispose() }) - it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { + it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) const ctx = new Context() @@ -498,19 +530,20 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) - const lateLoad = Promise.withResolvers<typeof snapshot>() - const loadStarted = Promise.withResolvers<undefined>() - ctx.sessionPersistence.load = (id) => { + const abandoned = preparationFromSnapshot(ctx, snapshot) + const latePreparation = Promise.withResolvers<SessionPreparation>() + const preparationStarted = Promise.withResolvers<undefined>() + ctx.sessionPersistence.prepare = (id) => { expect(id).toBe(sessionId) - loadStarted.resolve(undefined) - return lateLoad.promise + preparationStarted.resolve(undefined) + return latePreparation.promise } const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) - await loadStarted.promise + await preparationStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection @@ -518,10 +551,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(published).toEqual([]) expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - lateLoad.resolve(structuredClone(snapshot)) + latePreparation.resolve(abandoned) await Promise.resolve() await Promise.resolve() expect(published).toEqual([]) + abandoned[Symbol.dispose]() await ctx.fiber.dispose() }) @@ -530,7 +564,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // in its header) by creating it with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const adapter1 = new MockAdapter([textResponse('a')]) @@ -567,7 +601,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.fiber.dispose() }) - it('an idle inject() survives persist + resume without a synthetic turn', async () => { + it('a pending idle inject() survives persist + resume without a synthetic turn', async () => { const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent @@ -575,9 +609,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await waitForIdle(ctx1, a1) a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })) await a1.whenIdle() - await ctx1.fiber.dispose() + await ctx1.sessions.flush(a1.session) - // Lifecycle 2: resume; the injected context is still in the derived history. + // Lifecycle 2: resume; the injected context is still pending and becomes + // model-visible when the next turn admits it. const adapter2 = new MockAdapter([textResponse('next')]) const ctx2 = new Context() await ctx2.plugin(LlmService) @@ -588,10 +623,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) + const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess')) + expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(JSON.stringify(loaded.events)).toContain('background task 42 finished') const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent + expect(JSON.stringify(a2.inbox.nextStep)).toContain('background task 42 finished') + a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) + await waitForIdle(ctx2, a2) const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() + await ctx1.fiber.dispose() }) it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => { @@ -625,7 +667,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(a2.session.events.length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) expect(a2.session.events.at(-1)?.type).toBe('session/end-seed') - const replay = new Session(SessionId('replay'), events1) + const replay = Session.create(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. @@ -705,6 +747,24 @@ describe('creation and resume cancellation edges', () => { await ctx.fiber.dispose() }) + it('rejects when setup synchronously aborts its caller signal', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const controller = new AbortController() + + const creating = ctx.agents.create({ + sessionId: SessionId('setup-synchronous-abort'), + agentOptions: { provider: 'mock', model: 'mock' }, + signal: controller.signal, + setup() { + controller.abort(new Error('setup synchronously cancelled')) + }, + }) + + await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled') + expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('resume with a pre-aborted caller signal rejects out of the load race', async () => { const sessionId = SessionId('resume-pre-aborted') const root = await persistSession(sessionId) @@ -718,19 +778,45 @@ describe('creation and resume cancellation edges', () => { signal: controller.signal, }))).rejects.toThrow('resume abandoned') + const stringReason = new AbortController() + stringReason.abort('resume string reason') + await expect(promptly(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + signal: stringReason.signal, + }))).rejects.toThrow(/creation aborted/) + expect(ctx.agents.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) - it('factory teardown during a hung resume load rejects with loop-inactive', async () => { + it('releases a restored preparation if the loop becomes inactive before setup', async () => { + const sessionId = SessionId('resume-loop-inactive-after-prepare') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([])) + const loop = ctx.agentLoop as unknown as { + ownership: { isActive: () => boolean } + } + vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + })).rejects.toThrow('agent loop is not active') + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('factory teardown during a hung resume preparation rejects with loop-inactive', async () => { const sessionId = SessionId('resume-loop-teardown') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([])) const snapshot = await ctx.sessionPersistence.load(sessionId) - const gate = Promise.withResolvers<typeof snapshot>() - const loadStarted = Promise.withResolvers<undefined>() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const abandoned = preparationFromSnapshot(ctx, snapshot) + const gate = Promise.withResolvers<SessionPreparation>() + const preparationStarted = Promise.withResolvers<undefined>() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } @@ -738,27 +824,28 @@ describe('creation and resume cancellation edges', () => { resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - await loadStarted.promise - // Resolve the load only after teardown began: the post-load ownership + await preparationStarted.promise + // Resolve the preparation only after teardown began: the post-prepare ownership // check, not the abort race, must reject the wrapper. const rejection = expect(promptly(resuming)).rejects.toThrow() const disposal = ctx.fiber.dispose() - gate.resolve(structuredClone(snapshot)) + gate.resolve(abandoned) await rejection await disposal + abandoned[Symbol.dispose]() }) }) describe('configured-start failure edges', () => { - it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => { + it('a non-Error mid-prepare abort reason is wrapped for the resume caller', async () => { const sessionId = SessionId('resume-string-mid-abort') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([])) const gate = Promise.withResolvers<never>() gate.promise.catch(() => undefined) - const loadStarted = Promise.withResolvers<undefined>() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const preparationStarted = Promise.withResolvers<undefined>() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } const controller = new AbortController() @@ -768,7 +855,7 @@ describe('configured-start failure edges', () => { agentOptions: { provider: 'mock', model: 'mock' }, signal: controller.signal, }) - await loadStarted.promise + await preparationStarted.promise controller.abort('operator string reason') await expect(promptly(resuming)).rejects.toThrow(/creation aborted/) @@ -783,7 +870,7 @@ describe('configured-start failure edges', () => { // The artifact exists (list reports it) but its load fails: this is // corruption, not first creation — the failure must be reported, and no // fresh same-id session may shadow the broken one. - ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt')) + ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt')) const configured = new Context() await configured.plugin(LlmService) @@ -793,7 +880,7 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id) + configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) const configFailures: unknown[] = [] configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) const configWarnings: string[] = [] @@ -822,9 +909,9 @@ describe('configured-start failure edges', () => { const ctx = await mountPersistentHarness(root, new MockAdapter([])) const gate = Promise.withResolvers<never>() gate.promise.catch(() => undefined) - const loadStarted = Promise.withResolvers<undefined>() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const preparationStarted = Promise.withResolvers<undefined>() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } const failures: unknown[] = [] @@ -838,12 +925,12 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id) + configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) - await loadStarted.promise + await preparationStarted.promise const disposal = loop.dispose() gate.reject(new Error('late backend failure')) await disposal diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts new file mode 100644 index 0000000000..463515a61b --- /dev/null +++ b/packages/core/agent-loop/tests/runtime-context.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { RuntimeContextProjection } from '../src/runtime-context.ts' + +const SOURCE = '@deepseek-ai/dsh-system-prompt' + +function contextMessage(text: string) { + return createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: SOURCE }, + }) +} + +describe('RuntimeContextProjection', () => { + it('restores the latest visible owned snapshot and ignores other sessions', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('runtime-context-replay')) + const retained = session.append('user/message', contextMessage('retained'), { surfaceOp: 'append' }) + const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test-compaction' }, + }), { + surfaceOp: { op: 'replace', start: shadowed.seq, end: shadowed.seq }, + sourceEventSeqs: [shadowed.seq], + }) + + const projection = new RuntimeContextProjection(ctx, session) + expect(session.surface.nodes).toContain(retained.seq) + expect(projection.project('retained', [])).toBeUndefined() + expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({ + kind: 'plugin', + plugin: SOURCE, + form: 'snapshot', + sections: [{ name: 'sandbox:policy', text: 'policy' }], + }) + + const other = ctx.sessions.create(SessionId('runtime-context-other')) + other.append('user/message', contextMessage('other'), { surfaceOp: 'append' }) + expect(projection.project('retained', [])).toBeUndefined() + }) +}) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2420d20e8f..a618a130cf 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => { expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(SessionId('a1'))?.whenIdle() + await agent.whenIdle() }) it('records agents created through an agent context as non-root runtime children', async () => { @@ -1074,7 +1074,7 @@ describe('agent scope lifecycle', () => { await waitForIdle(ctx, agent) expect(reentered).toBe(true) - // Idle again: the reentrant admission was already claimed and settled (its + // Idle again: the reentrant batch was already claimed and settled (its // prompt was blocked by nothing, so it ran) — arm a SECOND reentry that // fires from the disposal cancel's idle transition itself. reentered = false diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index dd086d8e7f..f3548cca52 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -518,10 +518,10 @@ describe('tool-call scheduler: abort handling', () => { ]) }) - it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { + it('stops replenishing after abort, commits started results, and parks accepted additional contexts', async () => { const adapter = new MockAdapter([ multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), - textResponse('should never be requested'), + textResponse('after wake'), ]) const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') @@ -566,9 +566,23 @@ describe('tool-call scheduler: abort handling', () => { const settled = events(agent).filter(e => e.type === 'tool/result' || (e.type === 'user/message' && e.data.source.kind === 'plugin')) expect(settled.map(e => e.type)) - .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message']) - expect(settled.filter(e => e.type === 'user/message') - .map(e => (e.data.content[0] as { text: string }).text)) + .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result']) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'ctx-c1' }, + { type: 'text', text: 'ctx-c2' }, + ]) + + const idle = waitForIdle(ctx, agent) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } })) + await idle + + expect(events(agent).flatMap(e => + e.type === 'user/message' + && e.data.source.kind === 'plugin' + && e.data.content[0]?.type === 'text' + ? [e.data.content[0].text] + : [])) .toEqual(['ctx-c1', 'ctx-c2']) }) @@ -648,10 +662,6 @@ describe('tool-call scheduler: failure quiescence', () => { ? new Promise((_resolve, reject) => { rejectFirst = reject }) : dispatch(exec).then(() => { throw drainedError }) const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' }) - const errors: unknown[] = [] - ctx.on('agent/error', (subject, _turn, _step, error) => { - if (subject === agent) errors.push(error) - }) let idle = false const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true }) @@ -664,15 +674,16 @@ describe('tool-call scheduler: failure quiescence', () => { const startedBeforeDrain = [...gated.started] const idleBeforeDrain = idle - const errorsBeforeDrain = [...errors] + const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end') for (const id of gated.pending()) gated.release(id) await idlePromise expect(startedBeforeDrain).toEqual(['2']) expect(idleBeforeDrain).toBe(false) - expect(errorsBeforeDrain).toEqual([]) + expect(turnEndBeforeDrain).toBeUndefined() expect(gated.pending()).toEqual([]) - expect(errors).toEqual([schedulerError]) - expect(errors[0]).toBe(schedulerError) + expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } }, + }) }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 6f6ce8c263..8aec38004f 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -93,24 +93,18 @@ describe('loop-level canonical tool order', () => { expect(Object.isFrozen(adapter.requests[0])).toBe(true) }) - it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => { - // Unknown tool order fails before step or request creation and returns the agent to idle. + it('closes a no-step turn when toolOrder names an unregistered tool', async () => { const adapter = new MockAdapter([textResponse('never sent')]) const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST]) registerNamed(ctx, 'alpha') - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { - if (error instanceof Error) errors.push(error) - }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) expect(foldRequestHeader(agent.session.events)).toBeUndefined() - const end = agent.session.events.find(e => e.type === 'turn/end') - expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 }) - // The turn is balanced (turn/start → turn/end) with no step events inside. + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true) + expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false) + expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent-loop/tests/turn-admission.spec.ts b/packages/core/agent-loop/tests/turn-admission.spec.ts deleted file mode 100644 index e5d4d2ebb3..0000000000 --- a/packages/core/agent-loop/tests/turn-admission.spec.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import { MockAdapter, textResponse } from './mock-adapter.ts' - -async function harness(adapter: MockAdapter): Promise<Context> { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} - -function prompt(agent: Agent, text: string): void { - agent.followup(createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'user' }, - })) -} - -function itemText(item: InboxItem): string { - return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') -} - -interface InboxRecording { - readonly events: string[] - readonly enqueued: InboxItem['id'][] - readonly dequeued: InboxItem['id'][] - readonly discarded: InboxItem['id'][] -} - -/** Record the complete inbox lifecycle of one agent for order and identity assertions. */ -function recordInbox(ctx: Context): InboxRecording { - const events: string[] = [] - const enqueued: InboxItem['id'][] = [] - const dequeued: InboxItem['id'][] = [] - const discarded: InboxItem['id'][] = [] - ctx.on('agent/inbox/enqueue', (_agent, item) => { - events.push(`enqueue:${item.placement}:${itemText(item)}`) - enqueued.push(item.id) - }) - ctx.on('agent/inbox/dequeue', (_agent, item) => { - events.push(`dequeue:${itemText(item)}`) - dequeued.push(item.id) - }) - ctx.on('agent/inbox/discard', (_agent, items) => { - events.push(`discard:${items.map(itemText).join(',')}`) - discarded.push(...items.map(item => item.id)) - }) - return { events, enqueued, dequeued, discarded } -} - -/** Text of every ordinary prompt the log admitted, in durable order. */ -function promptTexts(agent: Agent): string[] { - return agent.session.events.flatMap(event => event.type === 'user/message' - ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) - : []) -} - -describe('idle turn admission reservation', () => { - it('holds later waking prompts in the FIFO until release', async () => { - const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const inbox = recordInbox(ctx) - - const release = agent.reserveTurnAdmission() - expect(release).toBeDefined() - - prompt(agent, 'first prompt') - prompt(agent, 'second prompt') - expect(agent.acceptsNextStep).toBe(false) - await new Promise<void>((resolve) => { setTimeout(resolve, 5) }) - - expect(agent.status).toBe('idle') - expect(adapter.requests).toHaveLength(0) - expect(agent.session.events).toHaveLength(0) - expect(inbox.events).toEqual([ - 'enqueue:queued:first prompt', - 'enqueue:queued:second prompt', - ]) - - release?.() - await agent.whenIdle() - - expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt']) - expect(agent.session.events.flatMap(event => - event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2]) - expect(inbox.events).toEqual([ - 'enqueue:queued:first prompt', - 'enqueue:queued:second prompt', - 'dequeue:first prompt', - 'dequeue:second prompt', - ]) - expect(inbox.dequeued).toEqual(inbox.enqueued) - expect(inbox.discarded).toEqual([]) - }) - - it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - prompt(agent, 'accepted first') - expect(agent.status).toBe('idle') - expect(agent.reserveTurnAdmission()).toBeUndefined() - - await agent.whenIdle() - expect(adapter.requests).toHaveLength(1) - }) - - it('refuses acquisition while a turn is running', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const reserved: unknown[] = [] - ctx.on('agent/step', () => { - reserved.push(agent.reserveTurnAdmission()) - }) - - prompt(agent, 'running') - await agent.whenIdle() - - expect(agent.status).toBe('idle') - expect(reserved).toEqual([undefined]) - }) - - it('refuses a second reservation and releases idempotently', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const release = agent.reserveTurnAdmission() - expect(agent.reserveTurnAdmission()).toBeUndefined() - prompt(agent, 'queued behind the reservation') - - release?.() - release?.() - await agent.whenIdle() - - expect(promptTexts(agent)).toEqual(['queued behind the reservation']) - expect(adapter.requests).toHaveLength(1) - const second = agent.reserveTurnAdmission() - expect(second).toBeDefined() - second?.() - }) - - it('ignores a stale release once a later reservation owns the boundary', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const stale = agent.reserveTurnAdmission() - stale?.() - const live = agent.reserveTurnAdmission() - prompt(agent, 'held by the live reservation') - stale?.() - await new Promise<void>((resolve) => { setTimeout(resolve, 5) }) - - expect(adapter.requests).toHaveLength(0) - live?.() - await agent.whenIdle() - expect(adapter.requests).toHaveLength(1) - }) - - it('acquires beside quiet queued work and leaves it queued', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - agent.send(createUserMessage({ - content: [{ type: 'text', text: 'quiet' }], - source: { kind: 'user' }, - }), { - target: 'next-turn', - wakeup: false, - }) - const release = agent.reserveTurnAdmission() - expect(release).toBeDefined() - - release?.() - await agent.whenIdle() - expect(adapter.requests).toHaveLength(0) - }) - - it('makes whenIdle() wait for release without spinning on a settled promise', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const machine = agent as Agent & { done: Promise<void> } - let backing = machine.done - let reads = 0 - Object.defineProperty(agent, 'done', { - configurable: true, - get(): Promise<void> { - reads += 1 - return backing - }, - set(value: Promise<void>) { - backing = value - }, - }) - - const release = agent.reserveTurnAdmission() - prompt(agent, 'waiting for the reservation') - let settled = false - const idle = agent.whenIdle().then(() => { settled = true }) - for (let tick = 0; tick < 5; tick += 1) { - await new Promise<void>((resolve) => { setTimeout(resolve, 1) }) - } - - expect(settled).toBe(false) - expect(reads).toBeLessThanOrEqual(2) - - release?.() - await idle - expect(settled).toBe(true) - expect(adapter.requests).toHaveLength(1) - }) - - it('resolves whenIdle() after release with nothing queued', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const release = agent.reserveTurnAdmission() - let settled = false - const idle = agent.whenIdle().then(() => { settled = true }) - await new Promise<void>((resolve) => { setTimeout(resolve, 5) }) - expect(settled).toBe(false) - - release?.() - await idle - expect(agent.status).toBe('idle') - }) - - it('lets cancellation discard held prompts and keeps the boundary quiet', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const inbox = recordInbox(ctx) - - const release = agent.reserveTurnAdmission() - prompt(agent, 'discarded while held') - agent.cancel({ kind: 'user' }) - - expect(inbox.events).toEqual([ - 'enqueue:queued:discarded while held', - 'discard:discarded while held', - ]) - expect(inbox.discarded).toEqual(inbox.enqueued) - expect(inbox.dequeued).toEqual([]) - - release?.() - await agent.whenIdle() - expect(adapter.requests).toHaveLength(0) - expect(agent.session.events).toHaveLength(0) - }) - - it('disposes the agent without waiting for the reservation to be released', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const handle = await ctx.agents.create({ - sessionId: SessionId('a1'), - agentOptions: { provider: 'mock', model: 'mock' }, - }) - const { agent } = handle - - const release = agent.reserveTurnAdmission() - prompt(agent, 'discarded by disposal') - await handle.dispose() - - expect(ctx.agents.list()).toEqual([]) - expect(adapter.requests).toHaveLength(0) - release?.() - }) -}) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 8673595322..4cfc5328e8 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 98421aa6de3d6778702665854ed723507e933028 -README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c +README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2 +README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 98421aa6de..c3d6e6c244 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. @@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. @@ -50,9 +50,11 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity. +`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. + +Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -60,18 +62,15 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. -- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or strictly steers one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Strict steer requires `acceptsNextStep`, ends the queued occurrence, and accepts the same immutable message as a new steering occurrence with a new `InboxItemId`; a closed window returns `steer-unavailable` without mutation. Pending steering and claimed occurrences return `not-found`. -- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it. -- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. -- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. -- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. -- `agent.session`, `agent.status`, `agent.options`, `agent.id` +- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending. +- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`. +- `agent.steer(message)` — queue waking `next-step` input. An idle agent starts a turn synchronously; a running driver consumes later steering at its next step boundary. +- `agent.inject(message)` — queue non-waking `next-step` context. A running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch. +- `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op. +- `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message. +- `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx` -`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. +`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. Only a caller that owns a complete interval may summarize it as a run result ([decision](../../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ### Extension points @@ -85,7 +84,7 @@ The handle every plugin programs against: #### What the model sees -`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself. +`send`, `steer`, and `inject` feed the owning session. `agent/pre-step` and other declared events let plugins reject a proposed step or add durable request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index bfc8d68a96..16ee8f5e6c 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -4,7 +4,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事件词汇。每个插件(UI、钩子、编排器)都面向此处定义的 `Agent` handle 编程;它不依赖循环,因此循环可以替换。 -可选配套包(package)`@deepseek-ai/dsh-agent/invariant` 会向 `ctx.invariants` 注册此包的 agent(智能体)状态转换检查。根 agent 服务不会隐式加载诊断。 +可选配套包`@deepseek-ai/dsh-agent/invariant`会向 `ctx.invariants` 注册此包的 agent(智能体)状态转换检查。根 agent 服务不会隐式加载诊断。 ## 服务:`AgentRegistry`(ctx 键:`agents`) @@ -12,9 +12,9 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 -`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 +`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。具体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 - `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber dispose。 - 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 @@ -36,11 +36,11 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 #### 工厂 seam(创建) -Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 +Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP(Agent Client Protocol)桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 - `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;dispose 时清空槽位。 -- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 @@ -50,9 +50,11 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 -`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。 +`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 + +inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 @@ -60,24 +62,21 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 -- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 -- `agent.updateInbox(itemId, action)`:同步编辑、移除一个仍处于待处理状态的 queued 入队项,或对其执行严格 steering。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。严格 steering 要求 `acceptsNextStep` 为 true;它会结束 queued 单次入队项,并把同一条不可变消息接受为新的 steering 单次入队项,后者使用新的 `InboxItemId`。窗口关闭时返回 `steer-unavailable`,且不做任何变更。待处理 steering 和已被认领的项会返回 `not-found`。 -- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 -- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。 -- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 -- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 -- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 -- `agent.whenIdle()`:agent 从 `running` 结算后达到完全停稳时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的完全停稳观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。 -- `agent.session`、`agent.status`、`agent.options`、`agent.id` +- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 用于变更队列;`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 +- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`。 +- `agent.steer(message)`:将会唤醒的 `next-step` steering(中途引导)输入排队。agent 空闲时会同步启动一个轮次;驱动器运行期间收到的后续 steering 会在下一个步骤边界被消费。 +- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。运行中的驱动器会在最近的后续 pre-step 边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。 +- `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作。 +- `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息。 +- `agent.session`、`agent.status`、`agent.options`、`agent.id`、`agent.ctx` -`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。 +`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。只有拥有完整区间的调用方才能将其概括为一次运行的结果([决策](../../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 ### 扩展点 - Agent 创建:`AgentLoop.create()` 是具体配置路径实现(位于 `dsh-agent-loop`),程序化消费方则通过 `ctx.agents.create()`/`ctx.agents.resume()` 创建或恢复有所有权的 agent。替换循环时,应实现 `Agent` 并通过 `ctx.agents.register()` 注册。 - 事件监听器:全部 `agent/*` 事件都在此处声明,不需要依赖循环包。 -- Subagent 委派不是 `Agent` 方法;提供方通过工厂 seam 创建或驱动普通 handle,因此委派传输留在核心 agent 接口之外。 +- subagent 委派不是 `Agent` 方法;提供方通过工厂 seam 创建或驱动普通 handle,因此委派传输留在核心 agent 接口之外。 ## 模型体验 @@ -85,7 +84,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, #### 模型看到的内容 -`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/prompt-submit`、`agent/step` 和其他已声明事件让插件能够阻止提示词或添加持久请求材料;此接口本身不贡献固定文案。 +`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/pre-step` 和其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料;此接口本身不贡献固定文案。 #### Token 影响 @@ -115,6 +114,6 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, - **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力契约。 - **委派以外的 agent 间通道**:共享状态、流式子输出和后台/轮询语义仍在当前同步 `ctx.subagents` seam 之外。 - **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。 -- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([停止表层 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。 +- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([关于停止操作接口的 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。 - **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入一个来源;无法表示混合来源。 - **`SessionStartSource` 预留 `'clear'`/`'compact'`,但还没有发出方**:在驱动子系统落地前,只会出现 `'startup'`/`'resume'`(`TODO(compaction)`)。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9113030eef..2e204bc7f0 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,24 +15,16 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", @@ -41,7 +33,6 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/core/agent/src/brand.ts b/packages/core/agent/src/brand.ts deleted file mode 100644 index 58d50259c1..0000000000 --- a/packages/core/agent/src/brand.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * dsh-agent's owned branded ids for live inbox occurrences. - * - * @module @deepseek-ai/dsh-agent/brand - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' - -/** - * Identifies one accepted occurrence in an agent inbox. Re-sending the same - * message creates a distinct item id, so pending work remains independently - * addressable. - */ -export type InboxItemId = Branded<'InboxItemId'> - -/** - * Brand a string as an {@link InboxItemId}. - * @param id - the agent-loop-minted occurrence identifier. - * @returns the same string, branded; no validation is performed. - */ -export function InboxItemId(id: string): InboxItemId { - return id as InboxItemId -} diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts new file mode 100644 index 0000000000..4448847cd8 --- /dev/null +++ b/packages/core/agent/src/inbox.ts @@ -0,0 +1,222 @@ +/** + * Incremental projection of durable agent inbox events. + * + * @module @deepseek-ai/dsh-agent/inbox + */ + +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' + +/** One of the two ordered pending-message lists owned by an agent. */ +export type InboxTarget = 'next-turn' | 'next-step' + +/** Mutable state privately owned by an {@link Inbox}. */ +type InboxState = Record<InboxTarget, UserMessage[]> + +/** Live notifications committed by inbox mutations. */ +export interface InboxNotifications { + /** Publish one inserted message. */ + inserted(message: UserMessage): void + /** Publish one discarded message. */ + discarded(message: UserMessage): void + /** Publish one claimed message inside its owning turn. */ + claimed(message: UserMessage, turn: number): void +} + +/** A replay-once projection that incrementally consumes later inbox splices. */ +export class Inbox { + private readonly state: InboxState = { 'next-turn': [], 'next-step': [] } + + constructor( + private readonly session: Session, + private readonly notifications: InboxNotifications, + ) { + for (const event of session.events.slice(session.header.seedLength ?? 0)) { + if (event.type !== 'agent/inbox/spliced') continue + try { + this.apply(event.data) + } catch (error: unknown) { + throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error }) + } + } + } + + /** Prompts awaiting individual turns. */ + get nextTurn(): readonly UserMessage[] { + return this.state['next-turn'] + } + + /** Input awaiting the next step boundary. */ + get nextStep(): readonly UserMessage[] { + return this.state['next-step'] + } + + /** Whether either pending-message list contains work. */ + get hasPending(): boolean { + return this.nextTurn.length > 0 || this.nextStep.length > 0 + } + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void { + this.splice('next-step', 0, this.nextStep.length, []) + this.splice('next-turn', 0, this.nextTurn.length, []) + } + + /** + * Remove and return the complete batch proposed for one step, publishing + * each claimed message. The durable splices are pure deletions. + * @param target - whether this boundary also consumes one queued turn. + * @param turn - turn that will own the claimed batch. + * @returns next-step input followed by the queued turn, when requested. + * @internal - the agent loop's step-boundary operation, not a plugin seam. + */ + claim(target: InboxTarget, turn: number): UserMessage[] { + const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) + if (target === 'next-turn') { + claimed.push(...this.mutate('next-turn', 0, 1, [], false)) + } + for (const message of claimed) this.notifications.claimed(message, turn) + return claimed + } + + /** + * Append one message to a pending list and durably record the insertion. + * @param target - pending list to extend. + * @param message - message to append. + * @throws if the message identity is already pending. + */ + append(target: InboxTarget, message: UserMessage): void { + this.splice(target, this.state[target].length, 0, [message]) + } + + /** + * Prepend one message to a pending list and durably record the insertion. + * @param target - pending list to extend. + * @param message - message to prepend. + * @throws if the message identity is already pending. + */ + prepend(target: InboxTarget, message: UserMessage): void { + this.splice(target, 0, 0, [message]) + } + + /** + * Replace one pending message in place, possibly changing its identity. A + * successful replacement publishes the old message as discarded and the new + * message as inserted. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + * @throws if the replacement duplicates another pending message identity. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean { + const location = this.locate(messageId) + if (location === undefined) return false + this.splice(location.target, location.index, 1, [newMessage]) + return true + } + + /** + * Remove one pending message and durably record its cancellation. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean { + const location = this.locate(messageId) + if (location === undefined) return false + this.splice(location.target, location.index, 1, []) + return true + } + + /** + * Apply standard splice semantics and durably record the normalized result. + * The durable event commits before the live projection mutates, so synchronous + * `session/event` observers see the pre-splice lists and can reconstruct the + * removed messages from the normalized coordinates. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] { + return this.mutate(target, start, deleteCount, inserted, true) + } + + /** Locate one pending identity across both owned lists. */ + private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { + for (const target of ['next-turn', 'next-step'] as const) { + const index = this.state[target].findIndex(message => message.id === messageId) + if (index >= 0) return { target, index } + } + return undefined + } + + /** Commit one normalized mutation and publish its live notifications. */ + private mutate( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + discardRemoved: boolean, + ): UserMessage[] { + const inbox = this.state[target] + const truncatedStart = Math.trunc(start) + const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart + const actualStart = offset < 0 + ? Math.max(inbox.length + offset, 0) + : Math.min(offset, inbox.length) + const truncatedDeleteCount = Math.trunc(deleteCount) + const actualDeleteCount = Math.min( + Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), + inbox.length - actualStart, + ) + if (actualDeleteCount === 0 && inserted.length === 0) return [] + const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined + const splice = { + target, + start: actualStart, + ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), + inserted, + ...(outcome === undefined ? {} : { outcome }), + } + this.validate(splice) + const event = this.session.append('agent/inbox/spliced', splice) + const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) + if (discardRemoved) { + for (const message of removed) this.notifications.discarded(message) + } + for (const message of event.data.inserted) this.notifications.inserted(message) + return removed + } + + /** Apply one normalized durable splice to the projection. */ + private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] { + this.validate(splice) + const inbox = this.state[splice.target] + return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + } + + /** Validate one normalized splice against the current projection. */ + private validate(splice: SessionEventMap['agent/inbox/spliced']): void { + const inbox = this.state[splice.target] + const removedCount = splice.removedCount ?? 0 + if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length + || !Number.isSafeInteger(removedCount) || removedCount < 0 + || splice.start + removedCount > inbox.length) { + throw new Error('invalid inbox splice') + } + const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted) + const ids = new Set<string>() + for (const message of splice.target === 'next-turn' + ? [...candidate, ...this.nextStep] + : [...this.nextTurn, ...candidate]) { + if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) + ids.add(message.id) + } + } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 66dee4efb7..0a16a2bf53 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' -export * from './brand.ts' +export * from './inbox.ts' export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' @@ -187,8 +187,8 @@ export interface AgentFactory { */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> /** - * Load a persisted session and resume an agent on it. Async because it awaits - * both `ctx.sessionPersistence.load` and the optional unpublished setup + * Prepare a persisted session and resume an agent on it. Async because it awaits + * both `ctx.sessionPersistence.prepare` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject * `sessionPersistence`). Publication follows the same setup-commit and * ordered boundary as {@link createAgent}. diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index 5051ac4e31..f2d9a69539 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -21,27 +21,6 @@ const install: InvariantInstaller = (ctx, fail) => { } lastStatus.set(agent, status) }, { global: true }) - - // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped - // (discard) only after it entered (enqueue), so the live outstanding count - // per agent can never go negative. Injection bypasses the FIFOs entirely and - // never appears on these events. - const outstanding = new WeakMap<Agent, number>() - ctx.on('agent/inbox/enqueue', (agent) => { - outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1) - }, { global: true }) - ctx.on('agent/inbox/dequeue', (agent) => { - const count = outstanding.get(agent) ?? 0 - if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue') - outstanding.set(agent, count - 1) - }, { global: true }) - ctx.on('agent/inbox/discard', (agent, items) => { - const count = outstanding.get(agent) ?? 0 - if (items.length > count) { - fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`) - } - outstanding.set(agent, count - items.length) - }, { global: true }) } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 80d289c9f0..e634762494 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -7,9 +7,10 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import type { InboxItemId } from './brand.ts' +import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +export type { AgentCancelCause } from '@deepseek-ai/dsh-session' +import type { Inbox, InboxTarget } from './inbox.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -28,131 +29,61 @@ export interface AgentOptions { maxTokens?: number } -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -export type SendTarget = 'next-turn' | 'next-step' - -/** Resolved inbox placement reported when an accepted message is enqueued. */ -export type InboxPlacement = 'queued' | 'steering' - -/** One independently addressable accepted occurrence in an agent inbox. */ -export interface InboxItem { - /** Agent-loop-minted occurrence identity. */ - readonly id: InboxItemId - /** Identified message delivered by the caller. */ - readonly message: UserMessage - /** Acceptance-time FIFO classification. */ - readonly placement: InboxPlacement -} - -/** A user-requested mutation of one still-pending queued occurrence. */ -export type InboxAction = - | { readonly kind: 'edit'; readonly content: ContentBlock[] } - | { readonly kind: 'remove' } - | { readonly kind: 'steer' } - -/** Result of applying an inbox action at the synchronous ownership boundary. */ -export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' - -/** Final admission outcome for one call to {@link Agent.steer}. */ -export type SteeringOutcome = - | { readonly status: 'admitted'; readonly turn: number; readonly step: number } - | { readonly status: 'rejected' } - -/** - * Message-owned steering admission receipt. The outcome promise always - * resolves: synchronous input validation still throws from {@link Agent.steer}, - * while lifecycle policy reports non-admission as `rejected`. - */ -export interface SteeringReceipt { - readonly outcome: Promise<SteeringOutcome> -} - -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -export interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} - /** Options for {@link Agent.cancel}. */ export interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no canceled inbox splice is logged. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } /** * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (the driver is draining - * work and may be closing or checkpointing a turn). Disposal removes the - * agent from its registry; it is not a third observable status. + * `idle` means no driver is active; `running` begins when waking input starts + * cancellable pre-step processing and lasts while the driver drains, + * closes, or checkpoints turns. Disposal removes the agent from its registry; + * it is not a third observable status. */ export type AgentStatus = 'idle' | 'running' -/** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. - */ -export type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } +/** Coordinates and cancellation for a proposed step. */ +export interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} -/** Model-request failure with an optional machine-routable provider code. */ -export type RequestError = Error & { code?: string } +/** Whether and with which messages the loop enters a proposed step. */ +export type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } + +/** One failed model-request attempt presented to recovery listeners. */ +export interface RequestFailureContext { + /** Turn containing the failed request. */ + readonly turn: number + /** Step containing the failed request attempt. */ + readonly step: number + /** Provider selected for the failed request. */ + readonly provider: string + /** Serializable facts normalized at the final adapter boundary. */ + readonly failure: LlmFailure + /** Policy of the adapter registration that served the failed request. */ + readonly retryPolicy: ResolvedRetryPolicy | undefined +} /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined -/** - * Why a turn ended, reported live on `agent/settled` right after the turn's - * durable `turn/end`. `error` carries the thrown value verbatim for observers; - * model-request recovery runs earlier through `agent/request-error`. - */ -export type SettleReason = - | { kind: 'completed' } - | { kind: 'aborted' } - | { kind: 'error'; error: unknown; failure?: LlmFailure } - /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Stable runtime cause accepted by {@link Agent.cancel}. */ -export type AgentCancelCause = - | { readonly kind: 'user' } - | { readonly kind: 'parent' } - -/** Runtime reason carried by the signal that controls one live turn. */ -export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } - -/** - * Public live-agent handle with aliases over the unified delivery primitive. - * @typert object - */ +/** Public live-agent handle. */ export interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -160,109 +91,72 @@ export interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - - /** - * Reserve admission of the next ordinary turn while this agent is idle, so an - * operation can mutate durable history before any queued prompt derives a - * request from it. Already-accepted waking work has right of way, including a - * send whose wake is still a pending microtask. Later sends keep their - * ordinary placement, FIFO order, and `wakeup` facts, and - * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes - * a queued follow-up rather than steering; cancellation and disposal may - * still discard them. {@link inject} is not withheld. {@link whenIdle} treats - * a live reservation as activity, while lifecycle teardown does not await it. - * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. - */ - reserveTurnAdmission(): (() => void) | undefined - - /** - * Mutate one still-pending queued occurrence synchronously. Editing preserves - * the message identity and queue position; removal publishes its terminal - * discard. Steer strictly transfers the message into the current next-step - * window, or returns `steer-unavailable` without changing the queued - * occurrence. Steering occurrences and driver-claimed items return - * `not-found`. - * @param id - independently addressable queued occurrence. - * @param action - edit, remove, or strict steer operation. - * @returns the applied outcome or the reason no mutation occurred. - */ - updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. - * @param cause - the stable caller intent carried by the current turn signal. + * turn or between-turn task. The first cause wins for that activity. With no + * active activity, cancellation is a no-op and does not arm later work. + * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work started before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no active driver or maintenance task remains. + */ whenIdle(): Promise<void> /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Run one non-turn maintenance task from the true idle phase. The task starts + * synchronously after claiming that phase; later waking input remains in the + * inbox until the task settles, while public status stays `idle`. + * `whenIdle()` follows both the task and any waking work released behind it. + * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. + * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @returns the task promise. + */ + runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> + + /** + * Route identified input to an inbox boundary and optionally wake the driver. + * Waking input submitted after active cancellation is queued for the next turn. + * @param message - identified content and its producer provenance. + * @param target - the preferred next-turn or next-step inbox boundary. + * @param wakeup - whether delivery may wake the driver. + */ + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + + /** + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering with a message-owned admission receipt — the - * `next-step`/wakeup preset of {@link send}. During prompt admission or an - * open turn, the message waits in the steering FIFO until a committed step - * snapshots it; outside that window it enters the ordinary queued FIFO. The - * receipt resolves `admitted` only after the message joins that step's - * immutable request history, or `rejected` when terminal policy, - * cancellation, or disposal discards it first. A non-terminal turn close may - * leave it staged for a later admitted prompt without settling the receipt. + * Submit steering for the nearest step. An idle driver starts a turn; + * a running driver consumes it at its next step boundary. + * A rejected step leaves steering parked in the inbox until the next + * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. - * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): SteeringReceipt + steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. A running driver claims it at the nearest later step boundary; + * idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void @@ -292,8 +186,9 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped<Agent>, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`). `send()` does not enter - * `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`). A waking delivery enters + * `running` synchronously after reserving cancellation; `idle` means no + * driver remains scheduled or active. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -301,57 +196,31 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void /** - * An item entered the queued or steering inbox. `placement` is the - * acceptance-time routing result; listeners must not reconstruct it from - * later agent or session state. - * @param agent - the owning agent. - * @param item - accepted occurrence, message, and resolved placement. + * One message entered the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void + 'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void /** - * A still-pending queued item changed content. The item id, placement, and - * position remain stable while the event carries the replacement message. - * @param agent - the owning agent. - * @param item - the complete post-update occurrence. + * One message left the inbox inside its open turn. If the proposed step + * is rejected, the claimed message ends here: it is neither discarded nor + * re-emitted as a user/message, and the turn closes without a step. + * @param agent - the agent whose inbox changed. + * @param event - the claimed message and owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void + 'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void /** - * The driver claimed one item out of the inbox: a queued item at a turn - * boundary, or steering drained between steps. Fires after the item leaves - * its FIFO and before it becomes a durable message. - * @param agent - the agent whose inbox item was claimed. - * @param item - the exact claimed occurrence. + * One message was discarded from the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void - /** - * Pending inbox items were dropped without delivering them, so every - * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, - * emits this after `agent/cancel-requested` when applicable and before - * aborting the active work. Fires once per drop with every dropped item. - * @param agent - the agent whose inbox items were dropped. - * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItem[]): void - /** - * Effective broad cancellation was requested, before queued/outbox work - * is cleared or the active turn is aborted. This observe-only notification - * cannot veto cancellation; listener failures are contained. - * @param agent - the agent whose current work is being cancelled. - * @param cause - the explicit typed cancellation cause. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void - + 'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use @@ -367,30 +236,15 @@ declare module 'cordis' { // ---- the machine's extension seams ---- /** - * Allow, rewrite, or block one claimed prompt before it becomes a user - * message or opens a turn. Call `next()` for the unchanged default. The - * signal controls only this admission attempt; listeners may cooperate with - * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose turn claimed the message. - * @param message - the frozen claimed message, including identity and source. - * @param signal - the current turn's explicit abort signal. + * Reject a proposed step or replace the messages that enter it. Calling + * `next()` preserves the current messages. + * @param agent - the agent proposing the step. + * @param messages - messages removed from the inbox for this step. + * @param context - proposed turn and step coordinates plus cancellation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision> - /** - * Awaited serial checkpoint before EVERY request of a turn is built (the - * first as well as each post-tools continuation). The single "between - * steps" extension point: inject context, steer, or edit the session log - * here — the request's history derives from the log right after this settles. - * @param agent - the agent about to send a request. - * @param turn - the open turn number. - * @param step - the step number about to open. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ - 'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void + 'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged @@ -405,24 +259,17 @@ declare module 'cordis' { */ 'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig> /** - * Handle a model-request failure after its failed step has closed but - * before the failed turn closes. A listener returns `{ kind: 'retry' }` - * without calling `next()` when it owns the error, or calls `next()` to - * delegate. The default `undefined` leaves the failure terminal. + * Handle one failed model-request attempt before the loop retries or closes + * its step. A listener returns `{ kind: 'retry' }` without calling `next()` + * when it owns recovery, or calls `next()` to delegate. The default + * `undefined` leaves the failure terminal. * @param agent - the agent whose request failed. - * @param turn - the open turn number. - * @param step - the failed step number. - * @param error - the original model-request failure. - * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another - * retry turn in this consecutive sequence. - * @param retryPolicy - immutable policy of the adapter registration that served - * the failed request, or `undefined` if no final adapter served it. + * @param context - request coordinates, provider, normalized failure, and serving policy. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> + 'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a @@ -430,7 +277,10 @@ declare module 'cordis' { * re-reads its inbox: fresh steering runs another step, none closes the * turn. Data decides, so listener order cannot change the outcome. The * inverse control (stop a tool loop early) is data too: a tool result - * carrying `concludesTurn` ends the turn at its step. + * carrying `concludesTurn` ends the turn at its step. The conclusion + * never short-circuits already-submitted next-step work: same-step + * `additionalContexts` or racing steering still runs, and the turn + * closes only when that inbox drains. * @param agent - the agent whose turn is at its stop boundary. * @param turn - the turn about to close. * @param signal - the current turn's explicit abort signal. @@ -438,25 +288,10 @@ declare module 'cordis' { * @mode serial */ 'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void - /** - * One drain chain reached its terminal turn: that turn's `turn/end` is - * already committed. Automatically recovered failed turns do not emit this - * notification, and neither does a run that aborts or fails before its - * `turn/start` commits — there is no durable turn to settle against. - * `reason` says why; model-request recovery is exhausted when an error - * reaches it. - * @param agent - the agent whose turn closed. - * @param turn - the terminal turn number. - * @param reason - why the terminal turn ended, with live error facts when it failed. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void - // ---- error notifications (emit) ---- /** - * A step or turn errored. The machine reports a failure here (plus the - * logger) even when the error has no in-turn position for a durable record. + * A step or turn errored. The machine reports a failure here even when + * the error has no in-turn position for a durable record. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. @@ -467,3 +302,20 @@ declare module 'cordis' { 'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void } } + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * One normalized mutation of an agent's durable pending-message lists. + * Live dispatch precedes projection mutation, so synchronous observers may + * read the pre-splice inbox to recover the removed messages. + */ + 'agent/inbox/spliced': { + target: InboxTarget + start: number + removedCount?: number + inserted: UserMessage[] + outcome?: 'canceled' + } + } +} diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 09f3af6cff..313850faa4 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' -import type { Events } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' +import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, + Inbox, } from '@deepseek-ai/dsh-agent' import type { @@ -16,25 +17,129 @@ import type { function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { const id = SessionId(rawId) + const session = Session.create(id) const agent: Agent = { id, options: {}, - session: new Session(id), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: new Context(), send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - reserveTurnAdmission: () => undefined, cancel() {}, - whenIdle() { return Promise.resolve() }, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } return Object.assign(agent, overrides) } +describe('Inbox', () => { + it('rejects an invalid durable splice during reconstruction', () => { + const session = Session.create(SessionId('invalid-inbox-replay')) + session.append('agent/inbox/spliced', { + target: 'next-turn', + start: 1, + inserted: [], + }) + + expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })) + .toThrow('invalid persisted inbox splice at session seq 0') + }) + + it('replaces a pending message by identity across both lists', () => { + const session = Session.create(SessionId('replace-inbox')) + const inserted: UserMessage[] = [] + const discarded: UserMessage[] = [] + const inbox = new Inbox(session, { + claimed: () => {}, + inserted: message => void inserted.push(message), + discarded: message => void discarded.push(message), + }) + const original = createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }) + const nextStep = createUserMessage({ + content: [{ type: 'text', text: 'step' }], + source: { kind: 'user' }, + }) + const replacement = createUserMessage({ + content: [{ type: 'text', text: 'replacement' }], + source: { kind: 'user' }, + }) + const editedStep = freezeMessage({ + ...nextStep, + content: [{ type: 'text', text: 'edited step' }], + }) + inbox.append('next-turn', original) + inbox.append('next-step', nextStep) + + expect(inbox.replace(createUserMessage({ + content: [{ type: 'text', text: 'missing' }], + source: { kind: 'user' }, + }).id, replacement)).toBe(false) + expect(inbox.replace(original.id, replacement)).toBe(true) + expect(inbox.replace(nextStep.id, editedStep)).toBe(true) + expect(inbox.nextTurn).toEqual([replacement]) + expect(inbox.nextStep).toEqual([editedStep]) + expect(discarded).toEqual([original, nextStep]) + expect(inserted).toEqual([original, nextStep, replacement, editedStep]) + expect(() => { inbox.replace(editedStep.id, replacement) }) + .toThrow(`message "${replacement.id}" is already pending`) + }) + + it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => { + const session = Session.create(SessionId('splice-inbox')) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const first = createUserMessage({ + content: [{ type: 'text', text: 'first' }], + source: { kind: 'user' }, + }) + const second = createUserMessage({ + content: [{ type: 'text', text: 'second' }], + source: { kind: 'user' }, + }) + + inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second]) + expect(inbox.nextTurn).toEqual([first, second]) + expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second]) + expect(inbox.remove(second.id)).toBe(false) + expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`) + }) + + it('clears both pending lists as durable cancellations', () => { + const session = Session.create(SessionId('clear-inbox')) + const discarded: UserMessage[] = [] + const inbox = new Inbox(session, { + claimed: () => {}, + inserted: () => {}, + discarded: message => void discarded.push(message), + }) + const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } }) + const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) + inbox.append('next-turn', nextTurn) + inbox.append('next-step', nextStep) + const beforeClear = session.events.length + + inbox.clear() + + expect(inbox.hasPending).toBe(false) + expect(discarded).toEqual([nextStep, nextTurn]) + expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' + ? event.data + : event.type)).toEqual([ + { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, + { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, + ]) + + inbox.clear() + expect(session.events).toHaveLength(beforeClear + 2) + }) +}) + describe('AgentRegistry', () => { it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() @@ -58,7 +163,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) }) + const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) }) expect(() => ctx.agents.enter(agent, undefined)) .toThrow('agent id "agent-id" does not match session id "session-id"') @@ -185,12 +290,26 @@ describe('agentEvents()', () => { 'agent event "agent/status" listener rejected: Error: async listener', ]) }) + + it('dispatches serial listeners with the fused agent subject', async () => { + const ctx = new Context() + const agent = stubAgent('serial-event') + const signal = new AbortController().signal + const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] + ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => { + await Promise.resolve() + heard.push({ agent: subject, turn, signal: receivedSignal }) + }) + + await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal) + + expect(heard).toEqual([{ agent, turn: 3, signal }]) + }) }) describe('explicit cancellation contract', () => { it('exposes the closed typed cancellation cause at the Agent seam', () => { expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>() - expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>() }) }) diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 7744726465..158376a3d7 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,7 +1,6 @@ -import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { InboxItemId, type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -44,51 +43,3 @@ describe('agent status invariants', () => { expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() }) }) - -describe('agent inbox invariants', () => { - let nextItem = 0 - const info = (placement: InboxPlacement = 'queued'): InboxItem => ({ - id: InboxItemId(`i-${nextItem++}`), - message: freezeMessage({ - id: MessageId('m'), - role: 'user' as const, - content: [], - source: { kind: 'user' as const }, - }), - placement, - }) - - it('accepts a dequeue and a discard covered by prior enqueues', async () => { - const ctx = await setup() - const agent = mockAgent('i1') - const at = scopeTarget(agent, agent) - expect(() => { - ctx.emit(at, 'agent/inbox/enqueue', agent, info()) - ctx.emit(at, 'agent/inbox/enqueue', agent, info('steering')) - ctx.emit(at, 'agent/inbox/dequeue', agent, info()) - ctx.emit(at, 'agent/inbox/discard', agent, [info()]) - }).not.toThrow() - }) - - it('rejects a dequeue with no outstanding item', async () => { - const ctx = await setup() - const agent = mockAgent('i2') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) }) - .toThrow(/without a matching prior enqueue/) - }) - - it('rejects a discard larger than the outstanding count', async () => { - const ctx = await setup() - const agent = mockAgent('i3') - const at = scopeTarget(agent, agent) - ctx.emit(at, 'agent/inbox/enqueue', agent, info()) - expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) }) - .toThrow(/dropped 2 items but only 1 were outstanding/) - }) - - it('accepts an empty discard against a fresh agent', async () => { - const ctx = await setup() - const agent = mockAgent('i4') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow() - }) -}) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index b6d6c9e6bf..1561175ed9 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../util/brand" - }, { "path": "../../core/scope" }, diff --git a/packages/core/agent/tsdown.config.ts b/packages/core/agent/tsdown.config.ts index e92275a7f5..3a0934ccf8 100644 --- a/packages/core/agent/tsdown.config.ts +++ b/packages/core/agent/tsdown.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'tsdown' -/** Build the package root and optional invariant companion as independent bundles. */ +/** Build the package root and companions as independent bundles. */ export default defineConfig([ { entry: ['lib/types/index.js'], diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index f4050f251f..c9212a3998 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/scope/README.md README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6 -README.zh.md: b060d8f8e44a28e717ee4724249b797941388798 +README.zh.md: 16ec60a5489f909a46fd5f803dbf08490cd07988 diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index b060d8f8e4..16ec60a548 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包(package)无需依赖 agent 即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。 ## 公开 API @@ -23,7 +23,7 @@ ## 设计契约 -注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 +注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 感知作用域的服务会定义具体 `ScopeLayer`,聚合各自不同的表与领域辅助函数。`ScopedLayers.effect()` 接受一个返回同步撤销函数的同步动作,在可选通知前安装该撤销函数,并且只有在完整聚合为空时才回收精确作用域层。`notify` 默认为 `true`;由所提供的回调决定观测方失败是向外抛出还是在内部处理。`EntryValues` 保持内部可见;存储类从包根而非 `/store` 子路径导入;共享存储不定义注册表专属的筛选或迭代策略。详见[共享作用域层存储 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)。 diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 4679e2755a..a752ee4c77 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index fff3107f48..e544c47987 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,21 +8,17 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({ - 'agent/cancel-requested': args => args[0], 'agent/created': args => args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], - 'agent/inbox/dequeue': args => args[0], - 'agent/inbox/discard': args => args[0], - 'agent/inbox/enqueue': args => args[0], - 'agent/inbox/update': args => args[0], - 'agent/prompt-submit': args => args[0], + 'agent/inbox/claimed': args => args[0], + 'agent/inbox/discarded': args => args[0], + 'agent/inbox/inserted': args => args[0], + 'agent/pre-step': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-start': args => args[0], - 'agent/settled': args => args[0], 'agent/status': args => args[0], - 'agent/step': args => args[0], 'agent/turn-stopping': args => args[0], 'approval/request': args => (args[0] as Record<string, unknown>)['agent'], 'goal/changed': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index a2b0b6bbe0..d647344537 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { InboxItemId, type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -44,33 +44,29 @@ describe('scoped-dispatch invariants', () => { content: [], source: { kind: 'user' }, }) - const item = { id: InboxItemId('i'), message, placement: 'queued' as const } const agentRows = { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, item], - 'agent/inbox/update': [agent, item], - 'agent/inbox/dequeue': [agent, item], - 'agent/inbox/discard': [agent, []], - 'agent/cancel-requested': [agent, { kind: 'user' }], + 'agent/inbox/inserted': [agent, { message }], + 'agent/inbox/claimed': [agent, { message, turn: 1 }], + 'agent/inbox/discarded': [agent, { message }], 'agent/session-start': [agent, 'startup'], - 'agent/step': [agent, 1, 1, signal], - 'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })], + 'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], 'agent/request-error': [ agent, - 1, - 1, - new Error('request'), - { message: 'request', code: 'UNKNOWN' }, - [], - undefined, + { + turn: 1, + step: 1, + provider: 'p', + failure: { message: 'request', code: 'UNKNOWN' }, + retryPolicy: undefined, + }, signal, () => Promise.resolve(undefined), ], 'agent/turn-stopping': [agent, 1, signal], - 'agent/settled': [agent, 1, { kind: 'completed' }], 'agent/error': [agent, 1, 0, new Error('x')], } satisfies { [K in AgentEventName]: EventArgs<K> } const rows: Array<[string, unknown[]]> = [ diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9f0aec4eb8..0b2d50957e 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763 -README.zh.md: 40c58a539d5027f2619b5b2102b94e76f2c73e23 +README.md: e2c014a1448125b23475d6cdf52c02f10fc54794 +README.zh.md: a4aeead796961bd66a6c7ca1f9a66dffb60eba5d diff --git a/packages/core/session/README.md b/packages/core/session/README.md index d78dc5bcfe..e2c014a144 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,9 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`. -- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. -- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -35,7 +34,7 @@ The store pairs announced creation with disposal, publishes post-commit append n ### Class: `Session` -Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. +Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.create()` and detached replay or inspection sessions through `Session.create()`; the detached factory does not publish lifecycle events or bind the session to a fiber. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over the complete identified, frozen messages stored by those entries. Assistant messages preserve provider/model provenance and adapter-private replay state in their model source. A surface rewrite rebuilds the projection; there is no raw-log fallback. @@ -43,44 +42,39 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`). `origin: 'subagent'` is a coarse product classification, not a continuation capability. Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit. +Session-event import separates ownership from message validation. `snapshotSessionEvent(event)` clones a borrowed event before validating and freezing its identified message. `adoptSessionEvent(event)` performs the same message work in place and returns the original event; callers may use it only when they transfer an exclusively owned object graph with no mutable child shared with another event. + ### Chunk-row storage codec (`chunk-rows.ts`) -Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only. +The shared [storage codec](src/chunk-rows.ts) losslessly converts event sequences to compact rows and back. It preserves unrecognized events verbatim and rejects malformed encoded rows; persistence backends decide whether to enable packed writes. ### Surface types -- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. -- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. -- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`. -- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. -- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log. -- `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`. +This package owns ordered surface projection, replacement validation, replay, and the type guards that distinguish append-origin from replacement events. The [surface type catalog](../../../docs/core-data-structures/session.md#surface-types) owns the exact shapes and field semantics. A human transcript must project append-origin events rather than `session.surface`, because landed replacements shadow history the reader already saw; model-facing consumers continue to read `session.surface`. ### Request-header reconstruction (`request-header.ts`) `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`request/context` records registration-bound metadata for the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity is still recorded with `contextWindow` absent, clearing any older known capacity. - -A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model. +A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision. `tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. ### Session event vocabulary (`types.ts`) -The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`. Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn. -Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. +Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery. -An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state. +An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. Every `SessionEvent` carries two optional top-level fields (structural metadata): @@ -89,12 +83,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, and assistant messages require provider/model provenance. Persistence owns read compatibility before constructing this current-format seed. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`. ## Model Experience @@ -103,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives the complete messages from `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 40c58a539d..a4aeead796 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -4,7 +4,7 @@ 事件溯源的会话日志和内存存储。`Session` 是 agent(智能体)全部交互历史的仅追加真源,LLM(大语言模型)消息历史由它*派生*。原始日志之上维护一个 **surface** 层(产生消息事件的有序投影),以便高效派生和压缩(compaction)。 -可选配套入口 `@deepseek-ai/dsh-session/invariant` 将此包(package)的关系轨迹检查注册到 `ctx.invariants`:序号单调递增、轮次/步骤闭合,以及同一步骤内的工具调用/结果配对。加载或重新加载时,它会回放现有会话;存储校验、快照、冻结、溯源信息和 surface 准入仍始终由根会话包负责。 +可选配套入口 `@deepseek-ai/dsh-session/invariant` 将此包的关系轨迹检查注册到 `ctx.invariants`:序号单调递增、轮次/步骤闭合,以及同一步骤内的工具调用/结果配对。加载或重新加载时,它会回放现有会话;存储校验、快照、冻结、溯源信息和 surface 准入仍始终由根会话包负责。 ## 服务:`SessionStore`(ctx 键:`sessions`) @@ -12,9 +12,8 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`origin` 和 `delegationDepth`。 -- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 -- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -27,7 +26,7 @@ - `enter(session)` 执行冲突检查,在不通知的情况下发布,并返回一个绑定到该条目的幂等脱离函数。允许并发准备相同 id,但只有一个条目能够成功进入;陈旧的脱离函数无法移除其替代项。 - `announce(session)` 发出唯一一次创建边,并拒绝重复或重入通知。该次分发期间请求的脱离操作会延后,之后再发出成对的释放边;未通知的条目不会发出任何生命周期边。 -`dsh-agent-loop` 使用这一拆分,以保证循环的最终刷新先于会话脱离;详见[所有权 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)。 +`dsh-agent-loop` 使用这一拆分,以保证循环的最终刷新先于会话脱离;详见[所有权 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)。 ### 实时服务事件 @@ -35,7 +34,7 @@ ### 类:`Session` -普通类(不是 Cordis 服务)。通过 `ctx.sessions.create()` 创建。 +普通类(不是 Cordis 服务)。活跃会话通过 `ctx.sessions.create()` 创建,脱离态的回放或检查会话通过 `Session.create()` 创建;脱离态工厂不会发布生命周期事件,也不会将会话绑定到 fiber。 - `session.append(type, data, opts?)` 会为持久数据和 surface 元数据制作快照并冻结它们,校验标记形态、溯源信息、替换覆盖完整性,以及仅修改内容的单个 `tool/result` 重写,随后同步提交,再在彼此独立的失败收容下通知观察者。对已附加会话的重入追加会被拒绝,运行时检查也覆盖扩宽后的联合类型和已加载日志。 - `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,其中包含这些条目存储的完整、带标识且冻结的消息。assistant 消息会在其模型来源中保留提供方/模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。 @@ -43,44 +42,39 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`)。`origin: 'subagent'` 是粗粒度产品分类,不代表具备继续执行能力。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 持久值需要一种已接受的表示,不能先检查再二次读取。`isJsonValue(value)` 是布尔判断函数;`snapshotJsonValue(value)` 在一趟迭代中校验并复制普通值,无效输入返回 `undefined`,getter 抛出的异常则向外传播。快照辅助函数接受除 `-0` 外的有限 JSON 数值(JSON 会将其改写为 `0`)、稠密普通数组、普通对象或 null 原型对象;它会在规范化前拒绝循环引用、不支持的标量和特殊原型,同时不施加调用栈深度限制。 +会话事件导入将所有权与消息校验分开处理。`snapshotSessionEvent(event)` 会先克隆借用的事件,再校验并冻结其中带标识的消息。`adoptSessionEvent(event)` 原地执行相同的消息处理并返回原事件;调用方只有在移交独占的对象图,且该对象图没有与其他事件共享可变子对象时,才可以使用此函数。 + ### 分片行存储编解码器(`chunk-rows.ts`) -提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks`、`reasoning-chunks` 或 `tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0`/`time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq`/`time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture(测试前置数据)读取器(`dsh-llm-replay`、`dsh-acp-snapshot`)共享同一编解码器;后端默认启用的 `packChunks` 配置只控制写入。 +共享的[存储编解码器](src/chunk-rows.ts)在事件序列与紧凑行之间无损转换。它会逐字保留无法识别的事件,并拒绝形态错误的编码行;是否启用打包写入由持久化后端决定。 ### Surface 类型 -- `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start` 到 `end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们。 -- `SurfaceIntent`:`{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,可进入 surface 的类型调用 `session.append()` 时必需的第三个参数。 -- `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。 -- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。 -- `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。 -- `isAppendSurfaceEvent(event)`/`isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录(transcript)的持久来源,而该记录并非模型可见的 surface:已落地的替换会遮蔽它所概括的范围,因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`。 +此包拥有有序 surface 投影、替换校验、回放,以及区分追加来源事件与替换事件的类型守卫。[surface 类型目录](../../../docs/core-data-structures/session.md#surface-types)拥有精确形状与字段语义。面向人的 transcript(文本记录)必须投影追加来源事件,而不是 `session.surface`,因为已落地的替换会遮蔽读者已经看到的历史;面向模型的消费方继续读取 `session.surface`。 ### 请求头重建(`request-header.ts`) `request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`request/context` 记录请求所解析到的路由的、绑定注册项的元数据,在其所属步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由仍会被记录,但 `contextWindow` 字段缺失,从而清除较早的已知容量。 - -`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。 +`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message` 和 `tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。 `tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。 ### 会话事件词汇(`types.ts`) -生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。 +生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存。 `SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。 -此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。 +此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。 -被中断的实时轮次以粗粒度的 `{ kind: 'aborted' }` 结果结束。调用方身份属于 Agent 的运行时取消信号,不属于持久 transcript(文本记录);资源释放仍是独立的 `{ kind: 'disposed' }` 终态。 +被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): @@ -89,12 +83,12 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 - 持久化插件:订阅 `session/event`(延后写入),并在 `session/flush`(受等待)及 fiber dispose(资源释放)时排空。持久后端读取日志并重新加载到实时会话;这类后端会把元数据 seam(`SessionHeader`、`session.header`)与日志一同存储。 -- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息,而粗粒度中止结果必须只含 `{ kind: 'aborted' }`(带旧版原因的记录会被拒绝)。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。 +- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息。持久化层在构造该当前格式 seed 前负责读取兼容性处理。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。 - 压缩:`dsh-compact-basic` 为摘要检查点追加一个替换用 `user/message`,而 `dsh-compact-tool-result-prune` 追加仅修改内容的 `tool/result` 替换。工具配对边界策略及其缓存归 [`dsh-compact` seam](../../compact/compact/README.md) 所有;此包拥有有序 surface 成员关系、替换校验与 `replaceGeneration`。 ## 模型体验 @@ -103,7 +97,7 @@ #### 模型看到的内容 -模型会原样接收 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。 +模型会原样接收 `user/message`、`assistant/message` 和 `tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。 #### Token 影响 @@ -141,7 +135,7 @@ 记录日志不会导致失效,精确重建会保持请求前缀一致。后续请求头若更改前缀、提示词或 schema,可能从第一处差异开始使复用失效。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 05075a9bd4..83be69528e 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 1fe3879074..d250998624 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,13 +13,15 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' -import { SurfaceManager } from './surface.ts' +import { deriveEventMessage, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' +export { SessionPreparation } from './preparation.ts' +export type { SessionPreparationOptions } from './preparation.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' @@ -27,26 +29,26 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts' export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' -export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' /** - * Find the latest closed message-triggered turn, ignoring other triggers and - * between-turn events. + * Find the latest closed turn that entered at least one model step, ignoring + * balanced no-step turns produced by rejection, empty input, or cancellation. * @param events - session events, or an owned suffix, to inspect. * @returns the latest matching turn end, or `undefined`. */ export function findLastMessageTurnEnd( events: readonly SessionEvent[], ): SessionEvent<'turn/end'> | undefined { - const messageTurns = new Set<number>() + const steppedTurns = new Set<number>() let latest: SessionEvent<'turn/end'> | undefined for (const event of events) { - if (event.type === 'turn/start') { - if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn) + if (event.type === 'step/start') { + steppedTurns.add(event.data.turn) continue } - if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event + if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event } return latest } @@ -103,17 +105,12 @@ declare module 'cordis' { } } -/** Detach, validate, and freeze the creation metadata published by a session. */ -function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { - const input: unknown = source === undefined - ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } - : source - const snapshot = snapshotJsonValue(input) - if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable') - if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { +/** Validate and freeze one detached creation header in place. */ +function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { throw new Error('session header is not a plain JSON record') } - const record = snapshot as Record<string, unknown> + const record = input as Record<string, unknown> if (record.version !== SESSION_FORMAT_VERSION) { throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`) } @@ -148,31 +145,78 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe return deepFreeze(record as unknown as SessionHeader) } +/** Validate and freeze one exclusively owned persistence header in place. */ +function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader { + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const prototype = Reflect.getPrototypeOf(input) + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('session header is not a plain JSON record') + } + } + return validateSessionHeader(id, input) +} + +/** Detach, validate, and freeze the creation metadata published by a session. */ +function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { + const input: unknown = source === undefined + ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } + : source + const snapshot = snapshotJsonValue(input) + if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable') + return validateSessionHeader(id, snapshot) +} + +/** + * Validate an exclusively owned event and deeply freeze its identified message + * without copying the event. The caller transfers an object graph that no + * producer retains and that shares no mutable children with another event. + * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed. + * @param event - exclusively owned event imported across a trusted boundary. + * @returns the same event object with a validated, deeply frozen message. + */ +export function adoptSessionEvent<T extends SessionEvent>(event: T): T { + assertMessageEventShape( + event, + `session event at seq ${event.seq}`, + ) + switch (event.type) { + case 'user/message': + deepFreeze(event.data) + break + case 'assistant/message': + case 'tool/result': + deepFreeze(event.data.message) + break + default: + // SessionEventMap is merge-extensible; plugin-owned events carry no core message. + break + } + return event +} + /** * Detach one event while preserving deep immutability for its identified message. * @param event - event imported across a query or persistence boundary. * @returns a detached event snapshot with a validated, deeply frozen message. */ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T { - const snapshot = structuredClone(event) - assertMessageEventShape( - snapshot, - `session event at seq ${snapshot.seq}`, - ) - switch (snapshot.type) { - case 'user/message': - deepFreeze(snapshot.data) - break - case 'assistant/message': - case 'tool/result': - case 'steering/message': - deepFreeze(snapshot.data.message) - break - default: - // SessionEventMap is merge-extensible; plugin-owned events carry no core message. - break + return adoptSessionEvent(structuredClone(event)) +} + +/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */ +function freezeRestoredObject<T extends object>(value: T): T { + const pending: object[] = [value] + while (pending.length > 0) { + // The non-empty check proves an object remains to visit. + // oxlint-disable-next-line typescript/no-non-null-assertion + const current = pending.pop()! + Object.freeze(current) + for (const key in current) { + const child = (current as Record<string, unknown>)[key] + if (child !== null && typeof child === 'object') pending.push(child) + } } - return snapshot + return value } /** Validate the fixed event envelope after one-pass JSON materialization. */ @@ -181,18 +225,36 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe if (event['type'] === 'request/header-delta') { throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`) } - const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs']) - if (Object.keys(event).some(key => !allowed.has(key)) - || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string' - || !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number' - || !Number.isSafeInteger(event['seq']) || event['seq'] < 0 - || !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number' - || !Number.isSafeInteger(event['time']) || event['time'] < 0 - || !Object.hasOwn(event, 'data')) { + for (const key in event) { + switch (key) { + case 'type': + case 'seq': + case 'time': + case 'data': + case 'surfaceOp': + case 'sourceEventSeqs': + break + default: + throw new Error(`seed event at index ${index} has an invalid event envelope`) + } + } + const type = event['type'] + const seq = event['seq'] + const time = event['time'] + if (typeof type !== 'string' + || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 + || typeof time !== 'number' || !Number.isSafeInteger(time) + || event['data'] === undefined) { throw new Error(`seed event at index ${index} has an invalid event envelope`) } - assertCurrentLlmShape(event, index) - assertCurrentTurnEndShape(event, index) + switch (type) { + case 'request/header': + case 'user/message': + case 'assistant/message': + case 'tool/result': + assertCurrentLlmShape(event, index) + break + } } /** Reject obsolete request headers and malformed messages at the seed/load boundary. */ @@ -218,10 +280,12 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v } const type = event['type'] if (type !== 'user/message' && type !== 'assistant/message' - && type !== 'tool/result' && type !== 'steering/message') return + && type !== 'tool/result') return assertMessageEventShape(event, `seed ${type} at index ${index}`) } +const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens']) + /** Validate adapter-default provenance imported from a durable request header. */ function assertAdapterDefaults( value: unknown, @@ -233,8 +297,7 @@ function assertAdapterDefaults( throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) } const defaults = value as Record<string, unknown> - const allowed = new Set(['reasoningEffort', 'maxTokens']) - if (Object.keys(defaults).some(key => !allowed.has(key)) + if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key)) || Object.values(defaults).some(marker => marker !== true) || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined || defaults['maxTokens'] === true && config['maxTokens'] === undefined) { @@ -246,7 +309,7 @@ function assertAdapterDefaults( function assertMessageEventShape(event: Record<string, unknown>, subject: string): void { const type = event['type'] if (type !== 'user/message' && type !== 'assistant/message' - && type !== 'tool/result' && type !== 'steering/message') return + && type !== 'tool/result') return const data = event['data'] const record = typeof data === 'object' && data !== null ? data as Record<string, unknown> @@ -296,22 +359,6 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string } } -/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */ -function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void { - if (event['type'] !== 'turn/end') return - const data = event['data'] - /* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */ - if (typeof data !== 'object' || data === null) return - const reason = (data as Record<string, unknown>)['reason'] - /* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */ - if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return - const record = reason as Record<string, unknown> - if (record['kind'] === 'aborted' - && (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) { - throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`) - } -} - /** Whether an unknown value carries the current provider/model pair. */ function hasProviderModel(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false @@ -378,7 +425,8 @@ const attachments = new WeakMap<Session, SessionEntry>() /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * - * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Plain class (not a Service) — create live instances via + * `ctx.sessions.create()` and detached instances via {@link create}. * Seeding with an existing event log replays/forks a session. * @typert object */ @@ -395,7 +443,7 @@ export class Session { /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a - * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. @@ -416,9 +464,7 @@ export class Session { * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage * boundary: a resumed session's constructor seed is its full stored log, * while its header keeps the original fork value — this field is the - * in-process construction fact. An explicitly supplied empty seed has the - * same value as no seed (0); its `session/end-seed` event preserves the - * lifecycle distinction. + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the * `session/end-seed` event, which is what a consumer reading STORED history @@ -433,7 +479,40 @@ export class Session { */ readonly firstLiveSeq: number - constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** + * Create a detached session by validating and snapshotting borrowed seed + * events and storage metadata. + * @param id - session identity. + * @param seed - optional borrowed replay or fork events. + * @param header - optional borrowed storage metadata. + * @returns a detached session. + */ + static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session { + return new Session(id, seed, header) + } + + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session { + return new Session(id, seed, header, 'restore') + } + + private constructor( + id: SessionId, + seed?: readonly SessionEvent[], + header?: SessionHeader, + mode: 'snapshot' | 'restore' = 'snapshot', + ) { + const restoredHeader = mode === 'restore' + ? validateRestoredSessionHeader(id, header) + : undefined if (seed !== undefined) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a @@ -445,7 +524,7 @@ export class Session { for (const [index, source] of seed.entries()) { // The seed is a persistence/replay boundary: validate and detach the // complete event in one lossless-JSON pass. - const snapshot = snapshotJsonValue(source) + const snapshot = mode === 'restore' ? source : snapshotJsonValue(source) if (snapshot === undefined) { throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } @@ -462,11 +541,11 @@ export class Session { } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } - this.log.push(deepFreeze(snapshot)) + this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot)) } } this.firstLiveSeq = this.log.length - this.header = snapshotSessionHeader(id, header) + this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped // because a cold session is resumed on first touch, so repeatedly opening @@ -608,23 +687,18 @@ export class Session { return this.headerFold } - /** Cached fold of the request-context events — see {@link requestContext}. */ + /** Cached fold of `request/context` events. */ private contextFold: RequestContext | undefined - /** Log position (events consumed) the context fold has reached. */ private contextFoldSeq = 0 /** - * The route metadata in force after the log's last `request/context` event — - * what the NEXT request deduplicates against — or undefined before any such - * record. Maintained incrementally like {@link requestHeader}, so a per-step - * read costs O(new events). - * @returns the folded context record, or undefined when none exists yet. + * Return the latest resolved route metadata, or `undefined` before the first + * `request/context` event. Each event is folded once. + * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined { if (this.contextFoldSeq < this.log.length) { for (const event of this.log.slice(this.contextFoldSeq)) { - // Frozen for the same reason as the header fold: it is session state - // exposed by reference and every later dedup compares against it. if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data }) } this.contextFoldSeq = this.log.length @@ -681,54 +755,13 @@ export class Session { } /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ deriveEventMessage(event: SessionEvent): Message | null { - // Intentionally non-exhaustive: only message-producing events derive - // history; turn/step boundaries, chunks, usage, and errors are - // trace/replay data. - - switch (event.type) { - // Ordinary prompts, injected context, and mid-turn steering project - // identically in user role: the event's model-facing content stays - // verbatim. Steering's `turn` is log-only. Do NOT - // re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is - // caller-owned — a producer bakes it into `content`, as workspace-context - // does with `<system-reminder>` — or, if reintroduced, must be driven by - // the event `meta` map and a dedicated renderer, keeping this projection a - // verbatim pass-through. See the deferred design note in - // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md - case 'user/message': { - return event.data - } - case 'steering/message': { - return event.data.message - } - case 'assistant/message': { - // Skip an empty-content assistant/message: it exists only to host a - // max-tokens step's usage and must not inject a content-less assistant - // turn into the provider transcript. - if (event.data.message.content.length === 0) return null - return event.data.message - } - case 'tool/result': { - return event.data.message - } - default: - // A non-surface event (boundary, chunk, log-only record) projects to - // no message. Merge-extensible union: no assertNever here. - return null - } + return deriveEventMessage(event) } } @@ -781,7 +814,7 @@ export class SessionStore extends Service { * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the - * loop's final flush is captured before the store attachment ends), do NOT use this + * loop's final events are published before the store attachment ends), do NOT use this * — fold the session lifecycle into the agent's own effect via * {@link prepare} + {@link enter} + {@link announce} (see * `dsh-agent-loop`'s creation transaction). @@ -813,16 +846,20 @@ export class SessionStore extends Service { * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE * effect so a fiber unload tears the session + agent down as a single ORDERED * chain rather than as racing sibling effects — which would remove the publication hooks - * before the loop's closing `session/flush`, dropping the closing events. + * before the driver's closing events commit, dropping them. * * @param id - the session id; omitted, the store mints `session-<n>`. - * @param options - seed events and/or creation metadata for the header. + * @param options - seed events and/or creation metadata for the header. With + * `seedSource: 'persistence'`, metadata and events must be fresh detached + * graphs whose ownership transfers to this call: they are validated and + * frozen in place through {@link Session.fromRestore}, so the caller must + * retain no mutable aliases. * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, metadata is not a plain * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path. */ - prepare(id?: SessionId, options?: CreateSessionOptions): Session { + prepare(id?: SessionId, options?: PrepareSessionOptions): Session { let sessionId: SessionId if (id === undefined) { do sessionId = SessionId(`session-${++this.counter}`) @@ -831,6 +868,9 @@ export class SessionStore extends Service { sessionId = SessionId(id) } if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) + if (options?.seedSource === 'persistence') { + return Session.fromRestore(sessionId, options.seed, options.meta) + } const seed = options?.seed const meta = options?.meta const header: SessionHeader = { @@ -843,7 +883,7 @@ export class SessionStore extends Service { ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, } - return new Session(sessionId, seed, header) + return Session.create(sessionId, seed, header) } /** @@ -967,10 +1007,11 @@ export class SessionStore extends Service { /** * Dispatch the awaited `session/flush` durability checkpoint for `session`, * with the carrier captured at {@link enter}. THE flush entry point: the - * store owns the carrier, so callers (the loop's turn-end checkpoint, idle - * injection, teardown drains) must come through here rather than dispatch a - * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the - * scoped-dispatch invariant can pin it. + * store owns the carrier, so callers (the checkpoint policy's per-request + * barrier, goal-session's idle checkpoint, teardown drains, and consumers + * that flush themselves before reading storage) must come through here + * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner, + * one spelling, and the scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. * @returns whether at least one durability listener participated, after every * listener has settled successfully. diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 0a51c9c6f0..f86b43716e 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -147,10 +147,9 @@ function validateEvent( case 'session/end-seed': // Unconstrained: an unbalanced seed legally puts it inside an open turn. break - case 'steering/message': case 'todo/write': - case 'request/context': - case 'request/header': { + case 'request/header': + case 'request/context': { if (trace.openTurn === null) { fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`) } diff --git a/packages/core/session/src/preparation.ts b/packages/core/session/src/preparation.ts new file mode 100644 index 0000000000..ee8fe53747 --- /dev/null +++ b/packages/core/session/src/preparation.ts @@ -0,0 +1,49 @@ +/** + * Ownership of one unpublished Session before registry publication. + * @module @deepseek-ai/dsh-session/preparation + */ + +import type { Session } from './index.ts' + +/** Options for a preparation whose provider retains unpublished state. */ +export interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} + +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +export class SessionPreparation implements Disposable { + private released = false + + /** The exact Session to use for setup and publication. */ + readonly session: Session + + private constructor( + session: Session, + private readonly options: SessionPreparationOptions, + ) { + this.session = session + } + + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation { + return new SessionPreparation(session, options ?? {}) + } + + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void { + if (this.released) return + this.released = true + this.options.release?.() + } +} diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index ad3d28127c..ad1c4b2ad3 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -8,6 +8,7 @@ * @module @deepseek-ai/dsh-session/surface */ +import type { Message } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts' /** Runtime counterpart of the message-producing event union. */ @@ -15,13 +16,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([ 'user/message', 'assistant/message', 'tool/result', - 'steering/message', ]) /** * Whether an event type can join the model-visible surface. * @param type - event type to test. - * @returns true for one of the four message-producing event types. + * @returns true for one of the three message-producing event types. */ export function isSurfaceEligibleType(type: string): boolean { return SURFACE_EVENT_TYPES.has(type) @@ -67,6 +67,52 @@ export function isReplacementSurfaceEvent( return isSurfaceEvent(event) && event.surfaceOp !== 'append' } +/** + * Project a single event into the LLM message it derives to, or null when it + * produces none — a non-surface event (chunk, boundary, log-only record) or an + * empty-content assistant/message (which exists only to host usage). This is + * THE per-node projection rule: `Session.deriveMessages` folds it over the + * live surface, external reconstructors and pure projections fold the same + * function over a log prefix's surface to rebuild the exact messages any + * request was built from. The returned message is the already frozen message + * nested in the event wrapper and shared by delivery, durable history, and + * model requests. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ +export function deriveEventMessage(event: SessionEvent): Message | null { + // Intentionally non-exhaustive: only message-producing events derive + // history; turn/step boundaries, chunks, usage, and errors are trace/replay + // data. + switch (event.type) { + // Ordinary prompts and injected context project in user role: the event's + // model-facing content stays verbatim. Do NOT re-add per-type framing + // (e.g. `<context>`) here: framing is caller-owned — a producer bakes it + // into `content`, as workspace-context does with `<system-reminder>` — or, + // if reintroduced, must be driven by the event `meta` map and a dedicated + // renderer, keeping this projection a verbatim pass-through. See the + // deferred design note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md + case 'user/message': { + return event.data + } + case 'assistant/message': { + // Skip an empty-content assistant/message: it exists only to host a + // max-tokens step's usage and must not inject a content-less assistant + // turn into the provider transcript. + if (event.data.message.content.length === 0) return null + return event.data.message + } + case 'tool/result': { + return event.data.message + } + default: + // A non-surface event (boundary, chunk, log-only record) projects to + // no message. Merge-extensible union: no assertNever here. + return null + } +} + /** One replacement operation observed while folding a session surface. */ export interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ @@ -242,13 +288,14 @@ function assertToolResultRewrite( event: SessionEvent, shadowedSeqs: readonly number[], events: readonly SessionEvent[], + baseSeq: number, ): void { if (event.type !== 'tool/result') return if (shadowedSeqs.length !== 1) { throw new Error('tool/result surface replacement must rewrite exactly one current node') } for (const originalSeq of shadowedSeqs) { - const original = events[originalSeq] + const original = events[originalSeq - baseSeq] if (original?.type !== 'tool/result') { throw new Error('tool/result surface replacement must target a current tool/result') } @@ -276,6 +323,7 @@ function planSurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, ): SurfacePlan | undefined { if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) @@ -288,7 +336,7 @@ function planSurfaceEvent( } const range = replacementRange(state, surfaceOp) assertProvenance(event, range.shadowedSeqs) - assertToolResultRewrite(event, range.shadowedSeqs, events) + assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq) return { kind: 'replace', seq: event.seq, @@ -304,8 +352,17 @@ function applySurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, +): SurfaceFoldReplacement | undefined { + const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) + return applySurfacePlan(state, plan) +} + +/** Commit one previously validated surface transition. */ +function applySurfacePlan( + state: SurfaceFoldState, + plan: SurfacePlan | undefined, ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event, expectedSeq, events) if (plan?.kind === 'append') { state.nodes.push(plan.seq) } else if (plan?.kind === 'replace') { @@ -331,7 +388,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { - const replacement = applySurfaceEvent(state, event, index, events) + const replacement = applySurfaceEvent(state, event, index, events, 0) if (replacement !== undefined) replacements.push(replacement) } return { nodes: [...state.nodes], replacements } @@ -341,38 +398,63 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() - /** Last processed seq; -1 folds a seeded log on first access. */ - private _lastProcessedSeq = -1 + /** Last processed absolute seq. */ + private _lastProcessedSeq: number + /** Candidate already validated by `validateNext`, pending exact log admission. */ + private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined - constructor(private log: readonly SessionEvent[]) {} + /** + * @param log - Contiguous complete log or loaded event window. + * @param baseSeq - Absolute sequence of the window's first event. + */ + constructor( + private log: readonly SessionEvent[], + private readonly baseSeq = 0, + ) { + this._lastProcessedSeq = baseSeq - 1 + } /** * Validate the next candidate without mutating the committed surface. * @param event - candidate event that has not entered the log yet. */ validateNext(event: SessionEvent): void { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - planSurfaceEvent(this._state, event, this.log.length, this.log) + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() + const expectedSeq = this.baseSeq + this.log.length + this._pendingPlan = { + event, + expectedSeq, + plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq), + } } /** Monotonic count of folded positional replacements. */ get replaceGeneration(): number { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.replaceGeneration } /** Surface event sequences in model-visible order. */ get nodes(): readonly number[] { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.nodes } /** Fold events appended since the previous access. */ private _processDelta(): void { - for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { + const tailSeq = this.baseSeq + this.log.length - 1 + for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) { + const index = seq - this.baseSeq // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - applySurfaceEvent(this._state, this.log[i]!, i, this.log) - this._lastProcessedSeq = i + const event = this.log[index]! + const pending = this._pendingPlan + if (pending?.event === event && pending.expectedSeq === seq) { + applySurfacePlan(this._state, pending.plan) + } else { + applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq) + } + if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined + this._lastProcessedSeq = seq } } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index e3fc6797e5..854e28d2e7 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -5,7 +5,6 @@ import type { LlmCallConfig, LlmCallConfigAdapterDefaults, LlmFailure, - MessageSource, StreamChunk, TokenUsage, ToolResultMessage, @@ -95,23 +94,32 @@ export interface CreateSessionOptions { } /** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. */ -export interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } +export interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' } -/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */ -export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] +/** Inputs accepted while constructing an unpublished Session. */ +export type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions + +/** Why an active agent driver was cancelled. */ +export type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } + +/** Durable cancellation cause, including imports whose original coarse record carried no cause. */ +export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' } /** * Why a turn ended. Merge-extensible sum type. @@ -119,20 +127,15 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] export interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: TurnEndCancelCause } + + blocked: { kind: 'blocked' } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. `error` is always a structured failure: the `LlmError` + * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }` + * flattened from any other error. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: LlmFailure } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -178,17 +181,13 @@ export interface EpochHeader { tools?: ToolSchema[] } -/** - * Registration-bound context metadata of one resolved model route. Adapter - * metadata about a route rather than a request input, which is why it lives - * outside {@link EpochHeader}. - */ +/** Registration-bound metadata for one resolved model route. */ export interface RequestContext { - /** Registered provider route the metadata was resolved through. */ + /** Registered provider route the metadata belongs to. */ provider: string /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + /** Maximum combined request and response context in tokens, when advertised. */ contextWindow?: number } @@ -208,14 +207,19 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' */ export interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn` before the loop claims queued input or runs pre-step. + * Rejection, empty input, cancellation, or failure may close it with no + * step; otherwise the following identified `user/message` event or batch + * records the messages entering the step. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** - * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * awaits `session/flush` after an ordinary turn ends before claiming the next - * queued item. Success commits the turn; rejection is reported live and does - * not prevent later work. + * Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn + * with no entered step has no `step/start` or `step/end`. The loop does not await a + * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the + * per-request durability checkpoint, and consumers that read storage after + * `whenIdle()` flush themselves. Success commits the turn; rejection is + * reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ @@ -226,9 +230,8 @@ export interface SessionEventMap { * A user-role message on the model-visible surface: a direct human prompt * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron - * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * notifications, …), or an entered goal continuation round. All three + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ @@ -264,8 +267,6 @@ export interface SessionEventMap { error?: { name: string; code: string } meta?: JsonValue } - /** Steering content injected between steps of a running turn. */ - 'steering/message': { turn: number; message: UserMessage } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -274,21 +275,14 @@ export interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context metadata for the route a request resolved to, - * appended inside its step beside `request/header` and only when the route - * or capacity differs from the last record. It is log-only and deliberately - * NOT part of {@link EpochHeader}: capacity is adapter metadata about a - * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. `contextWindow` is absent - * when the route's adapter advertises no capacity. + * Route metadata for the next request, logged only when the route or capacity + * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle - * produced none of them. An explicitly supplied empty seed puts the marker - * at seq 0, distinguishing an empty resumed session from a fresh session. - * This log-only event is the durable projection of + * produced none of them. This log-only event is the durable projection of * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` * carry the meaning. * @@ -322,7 +316,6 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'steering/message' /** * A {@link SessionEvent} that is **on** the ordered surface — its @@ -339,7 +332,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -374,7 +367,7 @@ export interface SurfaceIntent { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `steering/message`). + * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 2c87c0d98e..f93c898375 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,13 +16,13 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() } describe('derived-message cache', () => { it('stays deep-equal to a from-scratch replay derivation as the log grows', () => { - const session = new Session(SessionId('cache-grow')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('cache-grow')) + session.append('turn/start', { turn: 1 }) userText(session, 'one') expect(session.deriveMessages()).toEqual(scratch(session)) userText(session, 'two') @@ -54,8 +54,8 @@ describe('derived-message cache', () => { }) it('rebuilds on a surface replace and still matches scratch', () => { - const session = new Session(SessionId('cache-replace')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('cache-replace')) + session.append('turn/start', { turn: 1 }) userText(session, 'one') userText(session, 'two') const beforeReplace = session.deriveMessages() @@ -72,8 +72,8 @@ describe('derived-message cache', () => { }) it('returns a fresh array per call: later appends never grow a held snapshot', () => { - const session = new Session(SessionId('cache-snapshot')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('cache-snapshot')) + session.append('turn/start', { turn: 1 }) userText(session, 'one') const first = session.deriveMessages() userText(session, 'two') @@ -89,8 +89,8 @@ describe('derived-message cache', () => { describe('Session.deriveEventMessage — the per-event projection', () => { it('projects one appended event exactly as the full derivation projects its node', () => { - const session = new Session(SessionId('per-event')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('per-event')) + session.append('turn/start', { turn: 1 }) const event = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -99,8 +99,8 @@ describe('Session.deriveEventMessage — the per-event projection', () => { }) it('reuses the logged event\'s already frozen content', () => { - const session = new Session(SessionId('per-event-clone')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('per-event-clone')) + session.append('turn/start', { turn: 1 }) const event = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -113,8 +113,8 @@ describe('Session.deriveEventMessage — the per-event projection', () => { }) it('projects null for events that produce no message (boundaries, empty assistant)', () => { - const session = new Session(SessionId('per-event-null')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('per-event-null')) + session.append('turn/start', { turn: 1 }) const boundary = session.append('step/start', { turn: 1, step: 1 }) expect(session.deriveEventMessage(boundary)).toBeNull() const empty = session.append('assistant/message', { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index b0381f3c3f..0e7a0629c3 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -24,7 +24,7 @@ function appendClosedTurn( text = `hello ${turn}`, reason: TurnEndReason = { kind: 'completed' }, ): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -33,7 +33,7 @@ function appendClosedTurn( } function appendOpenTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `open ${turn}` }], source: { kind: 'user' }, @@ -138,18 +138,18 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const reasons: TurnEndReason[] = [ { kind: 'completed' }, - { kind: 'aborted' }, - { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' }, - { kind: 'disposed' }, + { kind: 'aborted', reason: { kind: 'user' } }, + { kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } }, + { kind: 'aborted', reason: { kind: 'disposed' } }, { kind: 'max-tokens' }, { kind: 'interrupted' }, ] - for (const reason of reasons) { - const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) + for (const [index, reason] of reasons.entries()) { + const source = ctx.sessions.create(SessionId(`parent-${index}`)) appendClosedTurn(source, 1, reason.kind, reason) - const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`)) + const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`)) expect(inherited(child).at(-1)?.type).toBe('turn/end') expect(child.header.seedLength).toBe(source.events.length) @@ -217,7 +217,7 @@ describe('SessionStore.fork', () => { it('rejects a detached Session object that is not live in ctx.sessions', async () => { const { sessions } = await setup() - const detached = new Session(SessionId('detached')) + const detached = Session.create(SessionId('detached')) expect(() => sessions.fork(detached)) .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) @@ -226,7 +226,7 @@ describe('SessionStore.fork', () => { it('rejects a stale Session object whose id is live on a different instance', async () => { const { ctx, sessions } = await setup() ctx.sessions.create(SessionId('same-id')) - const stale = new Session(SessionId('same-id')) + const stale = Session.create(SessionId('same-id')) expect(() => sessions.fork(stale)) .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) @@ -236,23 +236,23 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const cases: [string, (session: Session) => number][] = [ ['turn/start', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) return lastSeq(session) }], ['step/start', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) return lastSeq(session) }], ['user/message', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'open' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) return lastSeq(session) }], ['assistant/message', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, step: 1, @@ -269,7 +269,7 @@ describe('SessionStore.fork', () => { }], ['tool/call', (session) => { const callId = CallId('call-open') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, @@ -310,7 +310,7 @@ describe('SessionStore.fork', () => { it('rejects a duplicate child session id before validating the boundary', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('open-parent')) - source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + source.append('turn/start', { turn: 1 }) ctx.sessions.create(SessionId('child')) expect(() => sessions.fork(source, undefined, SessionId('child'))) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 83cdd29e15..cc5e21f57a 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -26,7 +26,7 @@ describe('session-log invariants', () => { await scopedCtx.plugin(SessionInvariant) const session = ctx.sessions.create(SessionId('global-under-scoped-invariants')) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() }) @@ -35,7 +35,7 @@ describe('session-log invariants', () => { const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -78,11 +78,10 @@ describe('session-log invariants', () => { }) expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('later dispatch veto') expect(session.events).toEqual([]) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() }) @@ -94,7 +93,7 @@ describe('session-log invariants', () => { const session = ctx.sessions.create(SessionId('postcommit-peer')) ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() expect(warnings).toHaveLength(2) @@ -107,7 +106,7 @@ describe('session-log invariants', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } as never) expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/end', @@ -120,30 +119,42 @@ describe('session-log invariants', () => { it('enforces turn numbering and core execution enclosure', async () => { const first = await setup() const open = first.ctx.sessions.create() - open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + open.append('turn/start', { turn: 1 }) + expect(() => open.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) .toThrow(/does not match open turn 1/) const second = (await setup()).ctx.sessions.create() - second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + second.append('turn/start', { turn: 1 }) second.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) + expect(() => second.append('turn/start', { turn: 3 })) .toThrow(/expected turn 2, got 3/) + const third = (await setup()).ctx.sessions.create() + third.append('turn/start', { turn: 1 }) + third.append('step/start', { turn: 1, step: 1 }) + third.append('step/end', { turn: 1, step: 1 }) + expect(() => third.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) + .not.toThrow() + + const enclosed = (await setup()).ctx.sessions.create() + enclosed.append('turn/start', { turn: 1 }) + enclosed.append('step/start', { turn: 1, step: 1 }) + expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow() + expect(() => enclosed.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + } as never)).not.toThrow() + expect(() => enclosed.append('request/context', { + provider: 'mock', model: 'mock', + })).not.toThrow() + const outside = (await setup()).ctx.sessions.create() expect(() => outside.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'idle context' }], source: { kind: 'plugin', plugin: 'test' }, }), { surfaceOp: 'append' })).not.toThrow() - expect(() => outside.append('steering/message', { - turn: 1, - message: createUserMessage({ - content: [{ type: 'text', text: 'go' }], - source: { kind: 'user' }, - }), - }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) // Route capacity is core execution state like the header beside it. expect(() => outside.append('request/context', { provider: 'mock', @@ -155,17 +166,16 @@ describe('session-log invariants', () => { expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow() expect(() => outside.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).not.toThrow() }) it('enforces open-step identity and numbering', async () => { const wrongTurn = (await setup()).ctx.sessions.create() - wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + wrongTurn.append('turn/start', { turn: 1 }) expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) const nested = (await setup()).ctx.sessions.create() - nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + nested.append('turn/start', { turn: 1 }) nested.append('step/start', { turn: 1, step: 1 }) expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) @@ -185,16 +195,21 @@ describe('session-log invariants', () => { }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/) const skipped = (await setup()).ctx.sessions.create() - skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + skipped.append('turn/start', { turn: 1 }) skipped.append('step/start', { turn: 1, step: 1 }) skipped.append('step/end', { turn: 1, step: 1 }) expect(() => skipped.append('step/start', { turn: 1, step: 3 })) .toThrow(/expected step 2 in turn 1, got 3/) + + expect(() => skipped.append('turn/end', { + turn: 1, + reason: { kind: 'completed' }, + })).not.toThrow() }) it('requires step-scoped stream and tool events to name the open step', async () => { const chunk = (await setup()).ctx.sessions.create() - chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + chunk.append('turn/start', { turn: 1 }) expect(() => chunk.append('assistant/chunk', { turn: 1, step: 1, @@ -202,7 +217,7 @@ describe('session-log invariants', () => { })).toThrow(/open is turn 1\/step null/) const tool = (await setup()).ctx.sessions.create() - tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + tool.append('turn/start', { turn: 1 }) tool.append('step/start', { turn: 1, step: 1 }) expect(() => tool.append('tool/result', { turn: 1, @@ -218,7 +233,7 @@ describe('session-log invariants', () => { it('keeps fresh tool-result appends open-step checked', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(() => session.append('tool/result', { turn: 1, step: 1, @@ -233,7 +248,7 @@ describe('session-log invariants', () => { it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, @@ -254,7 +269,7 @@ describe('session-log invariants', () => { session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) expect(() => session.append('tool/result', { ...original.data, message: freezeMessage({ @@ -273,7 +288,7 @@ describe('session-log invariants', () => { it('rejects a tool-result replacement outside a turn', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, @@ -312,7 +327,7 @@ describe('session-log invariants', () => { it('allows not-started repair results and unresolved calls at step end', async () => { const repaired = (await setup()).ctx.sessions.create() expect(() => { - repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + repaired.append('turn/start', { turn: 1 }) repaired.append('step/start', { turn: 1, step: 1 }) repaired.append('tool/result', { turn: 1, @@ -330,18 +345,18 @@ describe('session-log invariants', () => { const unresolved = (await setup()).ctx.sessions.create() expect(() => { - unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + unresolved.append('turn/start', { turn: 1 }) unresolved.append('step/start', { turn: 1, step: 1 }) unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) unresolved.append('step/end', { turn: 1, step: 1 }) - unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } }) }).not.toThrow() }) it('does not let a result in a later step satisfy an earlier call', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) session.append('step/end', { turn: 1, step: 1 }) @@ -360,22 +375,22 @@ describe('session-log invariants', () => { it('replays seeded sessions and tracks each session independently', async () => { const { ctx } = await setup() const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } }, + { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } }, ] expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) const a = ctx.sessions.create(SessionId('a')) const b = ctx.sessions.create(SessionId('b')) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + a.append('turn/start', { turn: 1 }) + expect(() => b.append('turn/start', { turn: 1 })) .not.toThrow() }) it('rebuilds trace state for sessions that exist when the companion reloads', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) await fiber.dispose() await ctx.plugin(SessionInvariant) @@ -384,7 +399,7 @@ describe('session-log invariants', () => { step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' }, })).not.toThrow() - expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + expect(() => session.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) }) @@ -392,16 +407,16 @@ describe('session-log invariants', () => { const { ctx } = await setup() // Balanced seed: between turns. expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] })).not.toThrow() // Unbalanced seed: inside the open turn, which the relation permits. const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] }) expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) // Still open afterwards: the boundary moves no cursor. - expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + expect(() => open.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow() }) @@ -409,11 +424,10 @@ describe('session-log invariants', () => { it('removes all listeners when the companion is disposed', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(() => session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, })).not.toThrow() }) }) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index c57eb247cb..32af9bbe48 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -70,7 +70,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof( // A non-message event (trace/replay data — must NOT affect derived history). const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof( - fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }), fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }), fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }), @@ -82,7 +82,7 @@ const logArb = fc.array(anyEventArb, { maxLength: 25 }) let counter = 0 function build(events: Appendable[]): Session { - const session = new Session(SessionId(`prop-${counter++}`)) + const session = Session.create(SessionId(`prop-${counter++}`)) for (const e of events) { // Forward the generated intent verbatim; non-surface events carry none. if (e.intent !== undefined) session.append(e.type, e.data, e.intent) @@ -110,7 +110,7 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events]) + const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. expect(replayed.events.slice(0, original.seq)).toEqual(original.events) @@ -121,8 +121,8 @@ describe('Session properties', () => { it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events]) - const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events]) + const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events]) + const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events]) // Lazy resume makes browsing a pickup, so this must not grow per open. expect(twice.events).toEqual(once.events) })) diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 0e51e52c55..b656a7116c 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -13,7 +13,7 @@ import type { SessionEvent, SurfaceEvent } from '../src/index.ts' */ const userTurnStart = (turn: number, seq: number): SessionEvent => - ({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + ({ type: 'turn/start', seq, time: seq, data: { turn } }) describe('interruptedTurnClosers', () => { it('returns nothing for a balanced log (ends on turn/end)', () => { diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index cd286aa133..5a24618fca 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -68,15 +68,15 @@ describe('foldRequestHeader', () => { it('returns the supplied baseline when no snapshot follows', () => { const from: EpochHeader = { config: CONFIG, system: 'baseline' } const unrelated: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] expect(foldRequestHeader(unrelated)).toBeUndefined() expect(foldRequestHeader(unrelated, from)).toBe(from) }) it('takes the latest full snapshot and skips unrelated events', () => { - const session = new Session(SessionId('fold')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('fold')) + session.append('turn/start', { turn: 1 }) session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, @@ -91,9 +91,9 @@ describe('legacy request-header format', () => { const legacy = [{ type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG }, }] as unknown as SessionEvent[] - expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) + expect(() => Session.create(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) - const session = new Session(SessionId('legacy-append-delta')) + const session = Session.create(SessionId('legacy-append-delta')) const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: CONFIG })) .toThrow(/unsupported legacy request\/header-delta/) @@ -104,10 +104,10 @@ describe('legacy request-header format', () => { const legacy = [{ type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' }, }] as unknown as SessionEvent[] - expect(() => new Session(SessionId('legacy-seed-reason'), legacy)) + expect(() => Session.create(SessionId('legacy-seed-reason'), legacy)) .toThrow('unsupported legacy request/header reason "fallback"') - const session = new Session(SessionId('legacy-append-reason')) + const session = Session.create(SessionId('legacy-append-reason')) const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' })) .toThrow('unsupported legacy request/header reason "fallback"') @@ -121,7 +121,7 @@ describe('Session.requestContext', () => { /** A turn-enclosed capacity record; the invariant rejects one outside a turn. */ function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] { const events: SessionEvent[] = [{ - type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, }] for (const data of records) { events.push({ type: 'request/context', seq: events.length, time: 1, data }) @@ -130,13 +130,13 @@ describe('Session.requestContext', () => { } it('reads undefined before any record exists', () => { - expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined() + expect(Session.create(SessionId('no-capacity')).requestContext()).toBeUndefined() }) it('folds a seeded log on first read, taking the last record', () => { // The fold watermark starts at 0 with the seed already in the log, so the // first read must consume the whole seed rather than skip it. - const session = new Session(SessionId('seeded-capacity'), seedWith( + const session = Session.create(SessionId('seeded-capacity'), seedWith( CAPACITY, { ...CAPACITY, model: 'later', contextWindow: 256_000 }, )) @@ -144,7 +144,7 @@ describe('Session.requestContext', () => { }) it('advances incrementally across appends and skips unrelated events', () => { - const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY)) + const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY)) expect(session.requestContext()).toEqual(CAPACITY) session.append('todo/write', { todos: [] }) expect(session.requestContext()).toEqual(CAPACITY) @@ -155,7 +155,7 @@ describe('Session.requestContext', () => { }) it('folds a batch appended between two reads', () => { - const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY)) + const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY)) expect(session.requestContext()).toEqual(CAPACITY) session.append('request/context', { ...CAPACITY, contextWindow: 200_000 }) session.append('todo/write', { todos: [] }) @@ -164,7 +164,7 @@ describe('Session.requestContext', () => { }) it('exposes a frozen record so a reader cannot desync later comparisons', () => { - const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY)) + const session = Session.create(SessionId('frozen-capacity'), seedWith(CAPACITY)) const held = session.requestContext() if (held === undefined) throw new Error('expected a folded capacity record') expect(Object.isFrozen(held)).toBe(true) diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index ffa436bc8b..441d2d8029 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -40,7 +40,7 @@ describe('session dispatch carriers', () => { otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`)) const session = scope.ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(heard).toEqual([ `owner-created:${session.id}`, @@ -57,7 +57,7 @@ describe('session dispatch carriers', () => { scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`)) const bare = ctx.sessions.create() - bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + bare.append('turn/start', { turn: 1 }) expect(heard).toEqual(['global:turn/start']) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e47f2fc452..1394db01e3 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -2,18 +2,35 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { - findLastMessageTurnEnd, + adoptSessionEvent, SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId, + findLastMessageTurnEnd, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { + it('finds the latest closed turn that entered a model step', () => { + const session = Session.create(SessionId('last-message-turn')) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) + + expect(findLastMessageTurnEnd(session.events)).toBeUndefined() + + session.append('turn/start', { turn: 2 }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('step/end', { turn: 2, step: 1 }) + session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) + + expect(findLastMessageTurnEnd(session.events)?.data) + .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) + }) + it('exposes one stable readonly surface view', () => { - const session = new Session(SessionId('surface-view')) + const session = Session.create(SessionId('surface-view')) const surface = session.surface expectTypeOf(surface).toEqualTypeOf<SessionSurface>() @@ -21,8 +38,8 @@ describe('Session', () => { }) it('derives message history from the event log', () => { - const session = new Session(SessionId('s1')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('s1')) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -61,8 +78,8 @@ describe('Session', () => { it('accepts and round-trips a max-tokens turn/end reason', () => { // The max-tokens TurnEndReason variant carries no extra data, so it must // append and persist like any other reason (JSON-serializable, no fields). - const session = new Session(SessionId('s1')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('s1')) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) const turnEnd = session.events.findLast(e => e.type === 'turn/end')! @@ -71,81 +88,27 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) - it('finds the latest message-turn outcome past later non-message turns', () => { - const session = new Session(SessionId('message-turn-outcome')) - expect(findLastMessageTurnEnd(session.events)).toBeUndefined() - session.append('turn/start', { - turn: 1, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'before' }], - source: { kind: 'plugin', plugin: 'before' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(findLastMessageTurnEnd(session.events)).toBeUndefined() - - session.append('turn/start', { - turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'bounded prompt' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) - session.append('turn/start', { - turn: 3, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'after' }], - source: { kind: 'plugin', plugin: 'after' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) - - expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) - }) - - it('round-trips the coarse aborted turn outcome', () => { - const session = new Session(SessionId('aborted')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) - const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events)) + it('round-trips an aborted turn with its cancellation cause', () => { + const session = Session.create(SessionId('aborted')) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events)) expect(replayed.events.slice(0, -1)).toEqual(session.events) const turnEnd = replayed.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) + .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) - it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => { - const legacy = [ - { - type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { - type: 'turn/end', seq: 1, time: 2, - data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } }, - }, - ] as unknown as SessionEvent[] - - expect(() => new Session(SessionId('legacy-aborted'), legacy)) - .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') - }) - - it('renders injected-context and steering messages as plain user content', () => { - const session = new Session(SessionId('s2')) + it('renders injected-context and user messages as plain user content', () => { + const session = Session.create(SessionId('s2')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, }), { surfaceOp: 'append' }) - session.append('steering/message', { - turn: 1, - message: createUserMessage({ - content: [{ type: 'text', text: 'focus on tests' }], - source: { kind: 'user' }, - }), - }, { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'focus on tests' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) const [contextMessage, steeringMessage] = session.deriveMessages() expect(contextMessage!.role).toBe('user') @@ -155,7 +118,7 @@ describe('Session', () => { }) it('keeps the exact identified context message in durable history and projection', () => { - const session = new Session(SessionId('s2-raw')) + const session = Session.create(SessionId('s2-raw')) const message = createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }], source: { kind: 'plugin', plugin: 'workspace-context' }, @@ -168,8 +131,8 @@ describe('Session', () => { }) it('replays identically from a seeded event log', () => { - const original = new Session(SessionId('s3')) - original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const original = Session.create(SessionId('s3')) + original.append('turn/start', { turn: 1 }) original.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -186,7 +149,7 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = new Session(SessionId('s3-replay'), [...original.events]) + const replayed = Session.create(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. expect(replayed.events.slice(0, original.seq)).toEqual(original.events) @@ -195,16 +158,16 @@ describe('Session', () => { }) it('marks an explicitly empty seed without marking a fresh session', () => { - const fresh = new Session(SessionId('fresh-empty')) + const fresh = Session.create(SessionId('fresh-empty')) expect(fresh.events).toEqual([]) - const resumed = new Session(SessionId('resumed-empty'), []) + const resumed = Session.create(SessionId('resumed-empty'), []) expect(resumed.firstLiveSeq).toBe(0) expect(resumed.events).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) - const reopened = new Session(SessionId('reopened-empty'), resumed.events) + const reopened = Session.create(SessionId('reopened-empty'), resumed.events) expect(reopened.firstLiveSeq).toBe(1) expect(reopened.events).toEqual(resumed.events) }) @@ -214,7 +177,7 @@ describe('Session', () => { type: 'request/header', seq: 0, time: 1, data: { header: { config: { model: 'old-model' } }, reason: 'initial' }, } as unknown as SessionEvent - expect(() => new Session(SessionId('old-header'), [requestHeader])) + expect(() => Session.create(SessionId('old-header'), [requestHeader])) .toThrow('seed request/header at index 0 lacks provider/model') const assistantMessage = { @@ -222,20 +185,20 @@ describe('Session', () => { data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] }, surfaceOp: 'append', } as unknown as SessionEvent - expect(() => new Session(SessionId('old-assistant'), [assistantMessage])) + expect(() => Session.create(SessionId('old-assistant'), [assistantMessage])) .toThrow('seed assistant/message at index 0 lacks an identified message') const malformedHeader = { type: 'request/header', seq: 0, time: 1, data: { header: 'old-header' }, } as unknown as SessionEvent - expect(() => new Session(SessionId('malformed-header'), [malformedHeader])) + expect(() => Session.create(SessionId('malformed-header'), [malformedHeader])) .toThrow('seed request/header at index 0 lacks provider/model') const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) + expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -287,6 +250,14 @@ describe('Session', () => { }, message: 'message has invalid source', }, + { + name: 'content shape', + event: { + type: 'user/message', seq: 0, time: 1, surfaceOp: 'append', + data: { ...user, content: 'not-an-array' }, + }, + message: 'message has invalid content', + }, { name: 'assistant source', event: { @@ -299,17 +270,6 @@ describe('Session', () => { }, message: 'message must have model source', }, - { - name: 'content block', - event: { - type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', - data: { - turn: 1, - message: { ...user, content: 'not-an-array' }, - }, - }, - message: 'message has invalid content', - }, { name: 'tool source', event: { @@ -353,7 +313,7 @@ describe('Session', () => { for (const { name, event, message } of invalid) { expect( - () => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]), + () => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]), name, ).toThrow(message) } @@ -364,13 +324,13 @@ describe('Session', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) expect(boundary).toEqual({ type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) const extended = snapshotSessionEvent({ @@ -389,6 +349,45 @@ describe('Session', () => { .toEqual([{ type: 'plugin-block', value: 1 }]) }) + it('adopts exclusively owned messages in place and keeps snapshots detached', () => { + const owned = { + type: 'user/message', + seq: 0, + time: 1, + surfaceOp: 'append', + data: { + id: 'owned-message', + role: 'user', + content: [{ type: 'text', text: 'owned' }], + source: { kind: 'user' }, + }, + } as SessionEvent<'user/message'> + expect(adoptSessionEvent(owned)).toBe(owned) + expect(Object.isFrozen(owned.data)).toBe(true) + expect(Object.isFrozen(owned.data.content)).toBe(true) + + const source = structuredClone(owned) + const snapshot = snapshotSessionEvent(source) + expect(snapshot).not.toBe(source) + expect(snapshot.data).not.toBe(source.data) + expect(snapshot.data.content).not.toBe(source.data.content) + }) + + it('validates message shape before adopting ownership', () => { + const malformed = { + type: 'user/message', + seq: 0, + time: 1, + data: { + id: 'wrong-role', + role: 'assistant', + content: [], + source: { kind: 'user' }, + }, + } as unknown as SessionEvent + expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"') + }) + it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => { const valid = { type: 'request/header', @@ -405,7 +404,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(new Session(SessionId('reasoning-effort'), [valid]).events[0]) + expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0]) .toEqual(valid) for (const reasoningEffort of ['', 1]) { @@ -413,7 +412,7 @@ describe('Session', () => { if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') const config = invalid.data.header.config as unknown as Record<string, unknown> config.reasoningEffort = reasoningEffort - expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid])) + expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid])) .toThrow('seed request/header at index 0 has an invalid reasoningEffort') } }) @@ -435,7 +434,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) for (const adapterDefaults of [ null, @@ -447,13 +446,13 @@ describe('Session', () => { const invalid = structuredClone(valid) as unknown as SessionEvent if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') invalid.data.header.adapterDefaults = adapterDefaults as never - expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid])) + expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid])) .toThrow('seed request/header at index 0 has invalid adapterDefaults') } }) it('isolates the log from mutation through a derived message (append-only contract)', () => { - const session = new Session(SessionId('s4')) + const session = Session.create(SessionId('s4')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -487,7 +486,7 @@ describe('Session', () => { }) it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => { - const session = new Session(SessionId('s5')) + const session = Session.create(SessionId('s5')) const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' }) expect(bad(1n)).toThrow(/non-JSON-serializable/) expect(bad(() => 0)).toThrow(/non-JSON-serializable/) @@ -514,8 +513,8 @@ describe('Session', () => { }) it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { - const session = new Session(SessionId('s5b')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('s5b')) + session.append('turn/start', { turn: 1 }) // A widened SessionEventType bypasses the overload's conditional requirement, // so the runtime guard must still reject the missing surface marker. const widenedType = 'user/message' as SessionEventType @@ -528,7 +527,7 @@ describe('Session', () => { }) it('accepts dense arrays and nested plain objects', () => { - const session = new Session(SessionId('s6')) + const session = Session.create(SessionId('s6')) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow() expect(session.events).toHaveLength(1) }) @@ -539,15 +538,15 @@ describe('Session', () => { const badSeed = [ { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } }, ] as unknown as SessionEvent[] - expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/) + expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/) }) it('validates seed events: rejects a non-contiguous seq', () => { const gapSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1 ] as SessionEvent[] - expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/) + expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/) }) it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => { @@ -556,24 +555,24 @@ describe('Session', () => { // so a resume/fork would silently lose history. append() forbids this at // compile time; a raw seed must be rejected at runtime to match. const markerlessSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const }, }) }, { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] - expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/) + expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/) }) it('accepts a well-formed contiguous serializable seed', () => { const goodSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const }, }), surfaceOp: 'append' as const }, { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] - const session = new Session(SessionId('seed-ok'), goodSeed) + const session = Session.create(SessionId('seed-ok'), goodSeed) expect(session.events.slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -583,7 +582,7 @@ describe('Session', () => { type: 'turn/start' as const, seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + data: { turn: 1 }, } const drifted = { ...accepted, seq: 99, data: { invalid: 1n } } let reads = 0 @@ -596,7 +595,7 @@ describe('Session', () => { }, }) - const session = new Session(SessionId('seed-entry-snapshot'), seed) + const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) expect(session.events.slice(0, 1)).toEqual([accepted]) @@ -613,7 +612,7 @@ describe('Session', () => { }) const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[] - const session = new Session(SessionId('seed-nested-drift'), seed) + const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) expect(session.events[0]!.data).toEqual({ value: 'accepted' }) @@ -630,7 +629,7 @@ describe('Session', () => { surfaceOp: { op: 'replace', start: 1n, end: 2 }, }] as unknown as SessionEvent[] - expect(() => new Session(SessionId('seed-bad-metadata'), seed)) + expect(() => Session.create(SessionId('seed-bad-metadata'), seed)) .toThrow(/losslessly JSON-serializable/) }) @@ -650,7 +649,7 @@ describe('Session', () => { surfaceOp: new ReplaceOp(), }] as unknown as SessionEvent[] - expect(() => new Session(SessionId('seed-exotic-metadata'), seed)) + expect(() => Session.create(SessionId('seed-exotic-metadata'), seed)) .toThrow(/losslessly JSON-serializable/) }) @@ -659,11 +658,11 @@ describe('Session', () => { readonly type = 'turn/start' as const readonly seq = 0 readonly time = 1 - readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } + readonly data = { turn: 1 } } const seed: SessionEvent[] = [new SeedEvent()] - expect(() => new Session(SessionId('seed-exotic-shell'), seed)) + expect(() => Session.create(SessionId('seed-exotic-shell'), seed)) .toThrow(/not losslessly JSON-serializable/) }) @@ -672,10 +671,10 @@ describe('Session', () => { type: 'turn/start' as const, seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + data: { turn: 1 }, }) as unknown as SessionEvent - const session = new Session(SessionId('seed-null-prototype'), [event]) + const session = Session.create(SessionId('seed-null-prototype'), [event]) expect(session.events.slice(0, 1)).toEqual([{ ...event }]) }) @@ -708,7 +707,7 @@ describe('Session', () => { sourceEventSeqs: [0], }] as unknown as SessionEvent[] - const session = new Session(SessionId('seed-unstable-metadata'), seed) + const session = Session.create(SessionId('seed-unstable-metadata'), seed) const event = session.events[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') @@ -745,7 +744,7 @@ describe('Session', () => { }] as unknown as SessionEvent[] try { - expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed)) + expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed)) .toThrow(`invalid seed event at index 1: ${expected}`) } finally { hasOwn.mockRestore() @@ -754,7 +753,7 @@ describe('Session', () => { it('snapshots the seed: mutating the original after construction does not affect session.events', () => { const seed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: { id: MessageId('seed-input'), role: 'user' as const, @@ -762,7 +761,7 @@ describe('Session', () => { }, surfaceOp: 'append' as const }, { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] - const session = new Session(SessionId('seed-snapshot'), seed) + const session = Session.create(SessionId('seed-snapshot'), seed) // Mutate the ORIGINAL seed objects after construction: a shared reference // would let this rewrite the forked log (or reintroduce non-serializable // data past validation). The snapshot must shield session.events. @@ -775,7 +774,7 @@ describe('Session', () => { }) it('snapshots append data: mutating the passed object after append does not affect session.events', () => { - const session = new Session(SessionId('append-snapshot')) + const session = Session.create(SessionId('append-snapshot')) const data = { id: MessageId('append-input'), role: 'user' as const, @@ -795,7 +794,7 @@ describe('Session', () => { }) it('reads a nested append-data getter once and stores its first JSON value', () => { - const session = new Session(SessionId('append-nested-drift')) + const session = Session.create(SessionId('append-nested-drift')) let reads = 0 const data = Object.defineProperty({}, 'value', { enumerable: true, @@ -813,7 +812,7 @@ describe('Session', () => { }) it('rejects non-JSON surface metadata before appending the event', () => { - const session = new Session(SessionId('append-bad-metadata')) + const session = Session.create(SessionId('append-bad-metadata')) expect(() => session.append( 'user/message', @@ -831,7 +830,7 @@ describe('Session', () => { readonly start = 0 readonly end = 0 } - const session = new Session(SessionId('append-exotic-metadata')) + const session = Session.create(SessionId('append-exotic-metadata')) expect(() => session.append( 'user/message', @@ -844,7 +843,7 @@ describe('Session', () => { }) it('reads a nested append-metadata getter once and stores its first JSON value', () => { - const session = new Session(SessionId('append-unstable-metadata')) + const session = Session.create(SessionId('append-unstable-metadata')) const source = session.append( 'user/message', createUserMessage({ @@ -875,7 +874,7 @@ describe('Session', () => { }) it('rejects invalid plain surface metadata shapes at append', () => { - const session = new Session(SessionId('append-invalid-surface-shape')) + const session = Session.create(SessionId('append-invalid-surface-shape')) const appendRaw = session.append.bind(session) as unknown as ( type: SessionEventType, data: unknown, @@ -896,7 +895,7 @@ describe('Session', () => { }) it('rejects surface metadata on non-surface append and seed events', () => { - const session = new Session(SessionId('non-surface-metadata')) + const session = Session.create(SessionId('non-surface-metadata')) const appendRaw = session.append.bind(session) as unknown as ( type: SessionEventType, data: unknown, @@ -905,34 +904,33 @@ describe('Session', () => { expect(() => appendRaw( 'turn/start', - { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + { turn: 1 }, { surfaceOp: 'append' }, )).toThrow(/not surface-eligible and cannot carry surfaceOp/) - expect(() => new Session(SessionId('non-surface-metadata-seed'), [{ + expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{ type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) expect(session.events).toEqual([]) }) it('deep-freezes seeded and appended event snapshots', () => { - const seeded = new Session(SessionId('seed-frozen'), [{ + const seeded = Session.create(SessionId('seed-frozen'), [{ type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) const seededEvent = seeded.events[0]! if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') expect(Object.isFrozen(seededEvent)).toBe(true) expect(Object.isFrozen(seededEvent.data)).toBe(true) - expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true) expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError) - const appended = new Session(SessionId('append-frozen')) + const appended = Session.create(SessionId('append-frozen')) const appendedEvent = appended.append('todo/write', { todos: [{ content: 'first', status: 'pending' }], }) @@ -943,9 +941,39 @@ describe('Session', () => { expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError) }) + it('iteratively freezes deeply nested restored event data', () => { + const depth = 20_000 + const data: Record<string, unknown> = {} + let tail = data + for (let index = 0; index < depth; index += 1) { + const child: Record<string, unknown> = {} + tail['child'] = child + tail = child + } + const event = { + type: 'test/deep-restore', seq: 0, time: 1, data, + } as unknown as SessionEvent + + expect(() => Session.fromRestore(SessionId('deep-restore'), [event], { + version: SESSION_FORMAT_VERSION, + id: SessionId('deep-restore'), + createdAt: 1, + })).not.toThrow() + + let current: unknown = event + let frozenNodes = 0 + for (let index = 0; index <= depth + 1; index += 1) { + if (!Object.isFrozen(current)) break + frozenNodes += 1 + current = (current as Record<string, unknown>)['data'] + ?? (current as Record<string, unknown>)['child'] + } + expect(frozenNodes).toBe(depth + 2) + }) + it('returns cached frozen event-array snapshots that do not grow after append', () => { - const session = new Session(SessionId('events-snapshot')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const session = Session.create(SessionId('events-snapshot')) + session.append('turn/start', { turn: 1 }) const before = session.events const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') @@ -973,7 +1001,7 @@ describe('Session', () => { seedLength: 2, } - const session = new Session(SessionId('header-owned'), undefined, input) + const session = Session.create(SessionId('header-owned'), undefined, input) input.cwd = '/caller-mutated' expect(session.header).toEqual({ @@ -998,15 +1026,24 @@ describe('Session', () => { readonly createdAt = 123 } - expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader())) + expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader())) .toThrow(/not losslessly JSON-serializable/) - expect(() => new Session(SessionId('header-invalid'), undefined, { + expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader())) + .toThrow(/not a plain JSON record/) + for (const header of [null, 1, []]) { + expect(() => Session.fromRestore( + SessionId('header-invalid'), + [], + header as unknown as SessionHeader, + )).toThrow(/not a plain JSON record/) + } + expect(() => Session.create(SessionId('header-invalid'), undefined, { version: SESSION_FORMAT_VERSION, id: SessionId('header-invalid'), createdAt: 123, parentSession: 1n, } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/) - expect(() => new Session(SessionId('header-invalid'), undefined, { + expect(() => Session.create(SessionId('header-invalid'), undefined, { version: SESSION_FORMAT_VERSION, id: SessionId('other'), createdAt: 123, @@ -1033,7 +1070,7 @@ describe('Session', () => { ] for (const { header, error } of cases) { - expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error) + expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error) } }) @@ -1042,7 +1079,7 @@ describe('Session', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } const cases: unknown[] = [ { ...base, extra: true }, @@ -1052,12 +1089,11 @@ describe('Session', () => { { ...base, seq: -1 }, { ...base, time: '1' }, { ...base, time: 0.5 }, - { ...base, time: -1 }, { type: base.type, seq: base.seq, time: base.time }, ] for (const [index, event] of cases.entries()) { - expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) + expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) .toThrow(/invalid event envelope/) } }) @@ -1081,7 +1117,7 @@ describe('SessionStore', () => { // may create an unrelated property with the old implementation's name, // but cannot suppress the durable event feed. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1099,7 +1135,7 @@ describe('SessionStore', () => { const a = ctx.sessions.create(SessionId('fixed')) expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1145,7 +1181,7 @@ describe('SessionStore', () => { const secondCtx = new Context() await firstCtx.plugin(SessionStore) await secondCtx.plugin(SessionStore) - const session = new Session(SessionId('owned-key')) + const session = Session.create(SessionId('owned-key')) const detachFirst = firstCtx.sessions.enter(session) expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/) @@ -1302,7 +1338,7 @@ describe('SessionStore', () => { }) it('a bare Session() constructed without the store still exposes a current-version header', () => { - const session = new Session(SessionId('bare')) + const session = Session.create(SessionId('bare')) expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' }) expect(typeof session.header.createdAt).toBe('number') }) @@ -1350,7 +1386,7 @@ describe('SessionStore', () => { ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1376,7 +1412,6 @@ describe('SessionStore', () => { expect(() => { appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) @@ -1415,14 +1450,12 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('reject first candidate') expect(session.events).toEqual([]) expect(observed).toEqual([]) const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([ { logLength: 0, frozen: true }, @@ -1438,7 +1471,7 @@ describe('SessionStore', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'source' }], @@ -1493,7 +1526,6 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('dispatch instrumentation rejected the carrier') expect(session.events).toEqual([]) expect(observed).toEqual([]) @@ -1513,7 +1545,6 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(session.events).toEqual([appended]) expect(heard).toEqual([appended]) @@ -1544,7 +1575,6 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(session.events).toEqual([appended]) @@ -1626,7 +1656,7 @@ describe('SessionStore', () => { it('does not let internal dispatch replace the disposed callback tuple', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const replacement = new Session(SessionId('replacement-disposed')) + const replacement = Session.create(SessionId('replacement-disposed')) const heard: Session[] = [] ctx.on('internal/dispatch', (_mode, name, args) => { if (name === 'session/disposed') args[0] = replacement @@ -1644,7 +1674,7 @@ describe('SessionStore', () => { describe('todo/write event', () => { it('appends the whole-list snapshot and isolates the log from later mutation', () => { - const session = new Session(SessionId('t1')) + const session = Session.create(SessionId('t1')) const todos: TodoItem[] = [ { content: 'plan the work', status: 'in_progress' }, { content: 'write the code', status: 'pending' }, @@ -1666,7 +1696,7 @@ describe('todo/write event', () => { }) it('is last-write-wins: the current list is the most recent todo/write', () => { - const session = new Session(SessionId('t2')) + const session = Session.create(SessionId('t2')) session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] }) session.append('todo/write', { todos: [ { content: 'first', status: 'completed' }, @@ -1681,7 +1711,7 @@ describe('todo/write event', () => { }) it('is NOT a surface event: it produces no derived message and joins no surface node', () => { - const session = new Session(SessionId('t3')) + const session = Session.create(SessionId('t3')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1694,12 +1724,12 @@ describe('todo/write event', () => { }) it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => { - const original = new Session(SessionId('t4')) - original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const original = Session.create(SessionId('t4')) + original.append('turn/start', { turn: 1 }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Seeding a non-surface event with no surfaceOp must not throw. - const replayed = new Session(SessionId('t4-replay'), [...original.events]) + const replayed = Session.create(SessionId('t4-replay'), [...original.events]) expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) expect(replayed.events.slice(0, original.seq)).toEqual(original.events) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 017f0e0163..4a7de73b03 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -9,6 +9,7 @@ import { isSurfaceEligibleType, isSurfaceEvent, } from '@deepseek-ai/dsh-session' +import { SurfaceManager } from '@deepseek-ai/dsh-session/surface' import { createMessage, createToolResultMessage, @@ -20,8 +21,8 @@ import { /** Build a minimal session with turn boundaries and a single user message. */ function surfaceSession(): Session { - const s = new Session(SessionId('ss')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('ss')) + s.append('turn/start', { turn: 1 }) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -95,7 +96,7 @@ describe('foldSurface provenance', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, sourceEventSeqs: [0], } as unknown as SessionEvent expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/) @@ -239,8 +240,55 @@ describe('foldSurface tool-result rewrites', () => { }) describe('SurfaceManager', () => { + it('folds a contiguous window without materializing earlier event sequences', () => { + const baseSeq = 400_000 + const events = [ + provenanceEvent(baseSeq, undefined), + provenanceEvent(baseSeq + 1, undefined), + { + ...provenanceEvent(baseSeq + 2, [baseSeq]), + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + }, + ] as SessionEvent[] + + const surface = new SurfaceManager(events, baseSeq) + expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1]) + expect(surface.replaceGeneration).toBe(1) + }) + + it('validates tool-result rewrites against a nonzero window offset', () => { + const baseSeq = 400_000 + const original = toolResultEvent(baseSeq, 'call') + const events: SessionEvent[] = [ + original, + { + ...original, + seq: baseSeq + 1, + time: baseSeq + 1, + surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + } as SessionEvent, + ] + + expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1]) + }) + + it('rejects a replacement that crosses a loaded window head', () => { + const baseSeq = 400_000 + const events = [ + provenanceEvent(baseSeq, undefined), + { + ...provenanceEvent(baseSeq + 1, [baseSeq - 1, baseSeq]), + surfaceOp: { op: 'replace', start: baseSeq - 1, end: baseSeq }, + }, + ] as SessionEvent[] + + expect(() => new SurfaceManager(events, baseSeq).nodes) + .toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`) + }) + it('shares ordered entries and nested replacement ranges with foldSurface', () => { - const s = new Session(SessionId('shared-fold')) + const s = Session.create(SessionId('shared-fold')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -284,7 +332,7 @@ describe('SurfaceManager', () => { }) it('does not retain fold-only replacement history in incremental state', () => { - const s = new Session(SessionId('incremental-state')) + const s = Session.create(SessionId('incremental-state')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -315,12 +363,12 @@ describe('SurfaceManager', () => { ] as SessionEvent[] expect(() => foldSurface(events)).toThrow(/start seq 42 not found/) - expect(() => new Session(SessionId('shared-fold-invalid'), events)) + expect(() => Session.create(SessionId('shared-fold-invalid'), events)) .toThrow(/start seq 42 not found/) }) it('leaves incremental state unchanged when candidate validation fails', () => { - const s = new Session(SessionId('atomic-validation')) + const s = Session.create(SessionId('atomic-validation')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -380,7 +428,7 @@ describe('SurfaceManager', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent @@ -397,8 +445,8 @@ describe('SurfaceManager', () => { }) it('empty surface yields empty nodes', () => { - const s = new Session(SessionId('empty')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('empty')) + s.append('turn/start', { turn: 1 }) s.append('step/start', { turn: 1, step: 1 }) s.append('step/end', { turn: 1, step: 1 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -431,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = new Session(SessionId('replay'), [...original.events]) + const replayed = Session.create(SessionId('replay'), [...original.events]) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) @@ -456,7 +504,7 @@ describe('SurfaceManager', () => { }) it('replace with both ends at real nodes splices only the range', () => { - const s = new Session(SessionId('range')) + const s = Session.create(SessionId('range')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -485,7 +533,7 @@ describe('SurfaceManager', () => { }) it('single-node replacement (start === end)', () => { - const s = new Session(SessionId('single')) + const s = Session.create(SessionId('single')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -511,7 +559,7 @@ describe('SurfaceManager', () => { }) it('throws when replace start is not found', () => { - const s = new Session(SessionId('bad-start')) + const s = Session.create(SessionId('bad-start')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -532,7 +580,7 @@ describe('SurfaceManager', () => { }) it('throws when replace end is not found', () => { - const s = new Session(SessionId('bad-end')) + const s = Session.create(SessionId('bad-end')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -553,7 +601,7 @@ describe('SurfaceManager', () => { }) it('throws when start is after end', () => { - const s = new Session(SessionId('reversed')) + const s = Session.create(SessionId('reversed')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -578,7 +626,7 @@ describe('SurfaceManager', () => { }) it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => { - const s = new Session(SessionId('immutable')) + const s = Session.create(SessionId('immutable')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -602,7 +650,7 @@ describe('SurfaceManager', () => { }) it('replace starting at non-head position preserves surrounding order', () => { - const s = new Session(SessionId('mid-replace')) + const s = Session.create(SessionId('mid-replace')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) // seq 0 @@ -631,7 +679,7 @@ describe('SurfaceManager', () => { }) it('surfaceOp replace object is snapshot so caller mutation is isolated', () => { - const s = new Session(SessionId('immutable-op')) + const s = Session.create(SessionId('immutable-op')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -666,8 +714,8 @@ describe('deriveMessages with surface', () => { }) it('surface path skips non-surface events (chunks, boundaries)', () => { - const s = new Session(SessionId('filter')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('filter')) + s.append('turn/start', { turn: 1 }) s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } }) s.append('user/message', createUserMessage({ @@ -690,7 +738,7 @@ describe('deriveMessages with surface', () => { }) it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => { - const s = new Session(SessionId('compacted')) + const s = Session.create(SessionId('compacted')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -711,18 +759,15 @@ describe('deriveMessages with surface', () => { expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' }) }) - it('injected-context and steering/message appear on surface', () => { - const s = new Session(SessionId('ctx')) + it('injected-context and user messages appear on surface', () => { + const s = Session.create(SessionId('ctx')) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' }, }), { surfaceOp: 'append' }) - s.append('steering/message', { - turn: 1, - message: createUserMessage({ - content: [{ type: 'text', text: 'focus' }], - source: { kind: 'user' }, - }), - }, { surfaceOp: 'append' }) + s.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'focus' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) const messages = s.deriveMessages() expect(messages).toHaveLength(2) expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }]) @@ -732,8 +777,8 @@ describe('deriveMessages with surface', () => { describe('Session.append surface opts', () => { it('records sourceEventSeqs and surfaceOp on the event', () => { - const s = new Session(SessionId('opts')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('opts')) + s.append('turn/start', { turn: 1 }) s.append('step/start', { turn: 1, step: 1 }) const event = s.append('assistant/message', { @@ -761,7 +806,7 @@ describe('Session.append surface opts', () => { // but _deriveOneMessage returns null for it, so the surface derivation path's // null-check is exercised — the node is on the surface yet produces no message. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, @@ -777,20 +822,20 @@ describe('Session.append surface opts', () => { { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const s = new Session(SessionId('nomessage'), seed) + const s = Session.create(SessionId('nomessage'), seed) // The empty assistant/message is on the surface but _deriveOneMessage returns null for it. expect(s.deriveMessages()).toHaveLength(0) }) it('a non-surface event carries no surface fields', () => { - const s = new Session(SessionId('noopts')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('noopts')) + s.append('turn/start', { turn: 1 }) expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined() expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined() }) it('surfaceOp primitives are not cloned (they are immutable)', () => { - const s = new Session(SessionId('prim')) + const s = Session.create(SessionId('prim')) const event = s.append('assistant/message', { turn: 1, step: 1, message: createMessage({ @@ -818,7 +863,7 @@ describe('Session.append surface opts', () => { } expect(isSurfaceEvent(noMarker)).toBe(false) // A non-surface type is rejected too (the type gate). - const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } + const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } } expect(isSurfaceEvent(boundary)).toBe(false) // A properly-marked surface event narrows. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent @@ -831,7 +876,6 @@ describe('surface type guards', () => { expect(isSurfaceEligibleType('user/message')).toBe(true) expect(isSurfaceEligibleType('assistant/message')).toBe(true) expect(isSurfaceEligibleType('tool/result')).toBe(true) - expect(isSurfaceEligibleType('steering/message')).toBe(true) expect(isSurfaceEligibleType('turn/start')).toBe(false) expect(isSurfaceEligibleType('assistant/chunk')).toBe(false) }) @@ -901,8 +945,8 @@ describe('surface type guards', () => { describe('SurfaceManager.replaceGeneration', () => { it('folds the pending log delta on access and counts replaces', () => { - const s = new Session(SessionId('gen')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const s = Session.create(SessionId('gen')) + s.append('turn/start', { turn: 1 }) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index a3937c24ec..36f166dd86 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e -README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788 +README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f +README.zh.md: b442239a50d539a8f079aa692c433d9defe57295 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d4e0f69323..23bc0e8177 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Model-input assembly registry. Plugins contribute ordered stable system sections, cache-safe dynamic context, tool schemas, and named variables. The loop assembles once per step, renders stable sections as the system prompt, and appends a durable full dynamic-context snapshot only when its text changes or compaction removed the retained snapshot. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default. +System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default. ## Config @@ -17,7 +17,6 @@ Model-input assembly registry. Plugins contribute ordered stable system sections ### Public API - `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. -- `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute cache-safe dynamic model context. Contexts are ordered independently from system sections; scoped contributions shadow same-named globals. The agent loop materializes the complete current set as one sourced user-role snapshot after retained history, only when changed or missing. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. @@ -30,17 +29,14 @@ Model-input assembly registry. Plugins contribute ordered stable system sections - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. - `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. -- `PromptContext` — `{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections. -- `PromptAssembly` — `{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. +- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. -- `renderContextSnapshot(assembly)` — applies the same strict interpolation to contexts, drops empty entries, and emits one full snapshot with an explicit supersession statement. An empty active set returns `''`; the loop emits one clearing snapshot when previously visible context disappears. Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging. ### Extension points - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. -- Context providers: policy and other changing-state owners contribute complete current facts without mutating the stable system prompt. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. - The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. @@ -69,20 +65,6 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token. -### Dynamic runtime context - -#### What the model sees - -Active contexts are joined in deterministic order after strict interpolation and logged as one sourced user-role message immediately before the request that first needs that snapshot. The message begins `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` A changed snapshot is appended after retained history; an unchanged retained snapshot adds nothing. If compaction removes it, the current full snapshot is emitted again. Removing the last context emits one explicit clearing snapshot. - -#### Token effect - -One concise message on the first request, on an effective context change, after compaction removed the retained snapshot, or when the active set becomes empty. Unchanged steps add no duplicate tokens. - -#### KV Cache effect - -Append-only after retained history. A context change preserves the previously cached system and conversation prefix instead of rewriting the first wire message. - ### Tool schemas #### What the model sees diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 47290335d7..b442239a50 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型输入组装注册表。插件贡献有序且稳定的系统段、缓存安全的动态上下文、工具 schema 和具名变量。循环在每个步骤组装一次,将稳定段渲染为系统提示词,并且仅在文本变化或压缩(compaction)移除了保留的快照时,追加一份持久的完整动态上下文快照。此插件拥有静态 harness 身份和全局部署 persona;agent(智能体)作用域的 persona 会遮蔽全局默认值。 +系统提示词组装注册表。插件贡献有序段、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整的模型提示词。此插件拥有静态 harness 身份和全局部署 persona;agent(智能体)作用域的 persona 会遮蔽全局默认值。 ## 配置 @@ -17,7 +17,6 @@ ### 公开 API - `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 -- `ctx.systemPrompt.context(context: PromptContext): () => void`:贡献缓存安全的动态模型上下文。上下文与系统段分别排序;带作用域的贡献会遮蔽同名全局项。仅在完整当前集合变化或缺失时,agent loop(智能体循环)会在保留的历史后将其具体化为一份带来源的 user 角色快照。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 @@ -30,22 +29,19 @@ - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 - `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 -- `PromptContext`:`{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约。 -- `PromptAssembly`:`{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 +- `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 -- `renderContextSnapshot(assembly)`:对上下文执行同样严格的插值,删除空条目,并发出一份带显式取代声明的完整快照。活动集合为空时返回 `''`;先前可见的上下文消失时,循环会发出一份清除快照。 可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly` 和 `AssembleContext` 声明额外字段。 ### 扩展点 - 段提供方:工具包(package)拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 -- 上下文提供方:策略及其他变化状态的归属方贡献完整的当前事实,而不改变稳定的系统提示词。 -- 变量提供方:agent loop 注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 +- 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 - 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 - [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 -设计原理:[提示词变量 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 +设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 ## 模型体验 @@ -69,20 +65,6 @@ You are an AI agent powered by the DeepSeek Harness SDK. 只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。 -### 动态运行时上下文 - -#### 模型看到的内容 - -活动上下文经过严格插值后按确定顺序连接,并在首次需要该快照的请求之前立即记录为一条带来源的 user 角色消息。消息以 `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` 开头。变化后的快照会追加到保留的历史之后;保留的快照未变时不会增加内容。如果压缩移除了它,当前完整快照会再次发出。移除最后一项上下文时会发出一份显式清除快照。 - -#### Token 影响 - -首次请求、上下文实际变化、压缩移除保留的快照或活动集合变空时,会增加一条简洁消息。未变化的步骤不会增加重复 token。 - -#### KV Cache 影响 - -在保留的历史之后仅追加。上下文变化会保留先前缓存的系统与对话前缀,而不会改写第一条 wire 消息。 - ### 工具 schema #### 模型看到的内容 diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 6cf94f477e..d601365df7 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 2e07fcfff2..16fc1394fa 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,5 +1,5 @@ /** - * Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables. + * Registry for ordered system sections, dynamic context, tool schemas, and prompt variables. * * @module @deepseek-ai/dsh-system-prompt */ @@ -8,7 +8,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' -import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { interface Context { @@ -65,15 +65,11 @@ export interface PromptSection { readonly text: string | ((context: AssembleContext) => string) } -/** - * One dynamic model-context contribution. Unlike a {@link PromptSection}, its - * rendered text is materialized as a durable user-role snapshot at the request - * tail, so changing runtime state preserves the stable system/history prefix. - */ +/** Dynamic model context materialized as a durable user-role snapshot. */ export interface PromptContext { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */ readonly name: string - /** Contexts are joined in ascending order, independently of system-section order. */ + /** Contexts are joined in ascending order. */ readonly order: number /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) @@ -87,11 +83,11 @@ export interface AssembledSection { text: string } -/** One dynamic context contribution with its text resolved. */ +/** One resolved dynamic context contribution. */ export interface AssembledContext { /** The contributing context's unique name. */ name: string - /** The resolved (but not yet interpolated) context text. */ + /** The resolved text before variable interpolation. */ text: string } @@ -105,8 +101,7 @@ export interface ToolProviderResult { /** * Merge-extensible assembled model input. Sections and contexts remain - * uninterpolated until their renderers; tools are already in canonical - * model-facing order. + * uninterpolated until rendered; tools are already in canonical order. */ export interface PromptAssembly { sections: AssembledSection[] @@ -200,22 +195,43 @@ export function renderPrompt(assembly: PromptAssembly): string { } /** - * Render the complete current dynamic context snapshot. The agent loop appends - * a new durable snapshot only when this text changes or is no longer retained - * after compaction; the explicit supersession clause makes older snapshots in - * history harmless. + * Render the complete dynamic context snapshot. * @param assembly - the assembly whose contexts and variables to render. * @returns the current full snapshot, or `''` when no context is active. */ export function renderContextSnapshot(assembly: PromptAssembly): string { - const body = assembly.contexts - .map(context => interpolate(context, assembly.variables, 'context')) - .filter(text => text.length > 0) - .join('\n\n') + return joinContextSections(renderContextSections(assembly)) +} + +/** + * The model-facing snapshot text for an already-rendered section list. + * + * A caller that also needs the sections renders them once and joins here, so a + * request does not interpolate every context twice. + * @param sections - sections from {@link renderContextSections}. + * @returns the current full snapshot, or `''` when no context is active. + */ +export function joinContextSections(sections: readonly ContextSnapshotSection[]): string { + const body = sections.map(section => section.text).join('\n\n') if (body.length === 0) return '' return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}` } +/** + * The same snapshot, kept as the named contributions it was assembled from. + * + * {@link renderContextSnapshot} joins these for the model; a consumer that + * presents the snapshot uses them to attribute each part to the subsystem that + * contributed it, without re-splitting the joined prose. + * @param assembly - the assembly whose contexts and variables to render. + * @returns one entry per contributing context that rendered to non-empty text. + */ +export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] { + return assembly.contexts + .map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') })) + .filter(section => section.text.length > 0) +} + /** Interpolate one section or context and attribute diagnostics to its owning input. */ function interpolate( input: AssembledSection | AssembledContext, @@ -348,10 +364,8 @@ export class SystemPrompt extends Service { } /** - * Register ordered cache-safe dynamic context in the calling context's scope. - * A scoped context shadows a global context with the same name; duplicates - * within one layer and non-finite orders throw. Registration and disposal - * emit `system-prompt/change`. + * Register ordered dynamic context in the calling context's scope. Scoped + * entries shadow global entries with the same name. * @param context - the context contribution to register. * @returns the exact Cordis effect disposer. */ diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 8fa82352b8..61bfcd005b 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f -README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883 +README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 1f0791c5df..691d2f2fcc 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -17,10 +17,10 @@ tools: ### 公开 API -- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(释放资源)。 +- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 -- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 +- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 - `ctx.tools.execute(exec)`:以无损方式快照并冻结参数,分配不透明 token,运行完整的策略/分发/结果流水线,然后在最终观测前独立快照权威结果。无效参数会进入同一结果路径,但不会到达策略或工具主体。环绕包装层只能替换 `signal`;注册表会在调用主体前立即重新融合调用方的原始信号。 - `ctx.tools.executionMode(exec)`:返回 `parallel` 的唯一条件是可见定义的 `isConcurrencySafe(exec.arguments)` 分类器恰好返回 `true`;未知、隐藏、未声明、无效或抛出异常的分类结果均为独占。 @@ -61,7 +61,7 @@ tools: ### 类型化工具参数 schema -第一方插件作者可以使用本包(package)导出的 `defineTool()` 辅助函数定义类型化工具参数 schema: +第一方插件作者可以使用本包导出的 `defineTool()` 辅助函数定义类型化工具参数 schema: ```ts import { readFile } from 'node:fs/promises' @@ -131,7 +131,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -在普通模式下,模型会看到每个可见定义的确切名称、描述和 JSON schema;已交付定义记录在生成的[工具包映射和 schema 章节](../../../docs/tool-catalog.md#tool-package-map)中。agent 作用域的限制、遮蔽和扩展注册会改变该 agent 的最终工具集合。 +在普通模式下,模型会看到每个可见定义的确切名称、描述和 JSON Schema;已交付定义记录在生成的[工具包映射和 schema 章节](../../../docs/tool-catalog.md#tool-package-map)中。agent 作用域的限制、遮蔽和扩展注册会改变该 agent 的最终工具集合。 #### Token 影响 @@ -182,7 +182,7 @@ The available tools: #### KV Cache 影响 -仅追加;新的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 0e719ab255..3279f17729 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index b06e29866d..7b037d266a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1278,7 +1278,7 @@ describe('the run_code dispatch bridge', () => { return Promise.resolve(observedDepth) }, })) - const session = new Session(SessionId('deep-code-arguments')) + const session = Session.create(SessionId('deep-code-arguments')) const agent = { session } as Agent runtime.behavior = async (request) => { let nested: JsonValue = 'leaf' @@ -1441,7 +1441,7 @@ describe('the run_code dispatch bridge', () => { }) it('a tool/code-dispatch event never derives a model message', () => { - const session = new Session(SessionId('code-mode-derive')) + const session = Session.create(SessionId('code-mode-derive')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 2c93403ce3..dae09f0591 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index f703934a5d..80ae299da7 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -98,7 +98,7 @@ describe('tool-pipeline invariants', () => { arguments: {}, } expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) @@ -107,7 +107,7 @@ describe('tool-pipeline invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/code-dispatch', { parentCallId: CallId('parent'), subCallId: CallId('child'), diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index e8b35ba48e..04f14e8e75 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/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/credentials/README.md -README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 -README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b +README.md: c08831c90333f8515bf86a5af717c38c50b50817 +README.zh.md: c9756c010af5d2db0fc41f3108eabdc7f1161293 diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 1d450cbeef..c08831c903 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -1,14 +1,12 @@ -# credentials/ +# credentials/ — credential references English | [中文](README.zh.md) -The credential capability seam, as three-package shape dictates (interface / implementation / consumers): +The credential capability family separates reference resolution from its provider: -| Package | Role | -|---|---| -| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | -| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | +| Package | Role | ctx key | +|---|---|---| +| [`credentials/`](credentials/README.md) | Credential-reference seam | `ctx.credentials` | +| [`credentials-local/`](credentials-local/README.md) | Environment and local-file provider | registers `ctx.credentials` | -Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. - -The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. +Configuration carries references, not secret values. Consumers resolve those references at their operation boundary; the child READMEs own mutation, precedence, and storage semantics. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index 843230c3ce..c9756c010a 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -1,14 +1,12 @@ -# credentials/ +# credentials/:凭据引用 [English](README.md) | 中文 -凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): +凭据能力家族将引用解析与提供方分离: -| 包 | 角色 | -|---|---| -| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | -| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | +| 包 | 角色 | ctx 键 | +|---|---|---| +| [`credentials/`](credentials/README.md) | 凭据引用 seam | `ctx.credentials` | +| [`credentials-local/`](credentials-local/README.md) | 环境与本地文件提供方 | 注册 `ctx.credentials` | -配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 - -seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 +配置携带引用而非机密值。消费方在其操作边界解析这些引用;变更、优先级与存储语义由子级 README 负责。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index b5fb4b2f0e..6575a13867 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 -README.zh.md: 59c7fd5747f327e8998882ca4db1473173e793b5 +README.zh.md: a279336f59ca9525ebeb918dd23fb0b7682443d5 diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 59c7fd5747..a279336f59 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md)提供方:两层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| -| 活跃进程环境 | `env` | 否 | 恒定优先 | +| 当前进程环境 | `env` | 否 | 始终优先 | | `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | 环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 @@ -22,33 +22,33 @@ ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的带引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会明确报错而不是被静默破坏。空的存储值等于不存在(seam 规则)。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则明确报错。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 -这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。 -## Model Experience +## 模型体验 -经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 +经由消费它的 LLM(大语言模型)适配器间接生效:存储的值为适配器向提供方发出的请求授权,所有模型可见内容均由适配器负责。 -#### KV Cache effect +#### KV Cache 影响 无直接失效;凭据绝不进入请求前缀。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串 provider 仍是延后项。 -- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。 +- **无法表示的值明确报错**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 -- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 +- **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..6cfa6fad5f 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index d907b0a1bb..42dd183fc4 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index 0b0ee3db7c..7b75fa452d 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 -README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 +README.md: 64fff8cb3f53386d48a9a831c1cbdd946ad483cc +README.zh.md: c346a41d297a545991a2441df625286e1b830998 diff --git a/packages/examples/README.md b/packages/examples/README.md index d3ad432e71..64fff8cb3f 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -6,17 +6,13 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack | -| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` | -| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | +| [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | Reusable agent-spine bundle | +| [`cli-demo/`](cli-demo/README.md) | `@deepseek-ai/dsh-cli-demo` | Headless one-shot application bundle | +| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | +| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` add their front doors, while `jsonrpc-demo` boots a deployment-owned plugin tree. -These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. +These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions. Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. - -## The jsonrpc bin/exe names are legacy - -`jsonrpc-demo` renamed like its siblings, but its bin is still `dsh-jsonrpc-agent` and the single-file executable is still `dsh-jsonrpc-agent-pkg` (referenced across the [Python distribution](../../python/sdk-runtime/README.md)). Those names are the SDK's runtime-startup surface; they are reconciled when the SDK unifies that startup flow, not by this move. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 0218e60df2..c346a41d29 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -6,17 +6,13 @@ | 包 | npm 名称 | 角色 | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | -| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | -| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | +| [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | 可复用的 agent 主干组合包 | +| [`cli-demo/`](cli-demo/README.md) | `@deepseek-ai/dsh-cli-demo` | 无头单次应用组合包 | +| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP 自动化应用组合包 | +| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 | -`agent-spine-demo` 是共享组合包;`cli-demo` 和 `acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。 +`agent-spine-demo` 是共享组合包;`cli-demo` 与 `acp-demo` 添加各自的前端入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。 -这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 +这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包只选择具体组合。 不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 - -## jsonrpc bin/exe 名称是历史遗留 - -`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index 01a4e4cd62..ff17cf5bb7 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md README.md: 395ab230146568989c4e6d1361218efb72d857e7 -README.zh.md: bfba1f83e506e60ab117f11f85851b3d3a7ea16a +README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6 diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index bfba1f83e5..667fc1a794 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能体)主干、客户端通过 [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md) 创建的 agent、JSONL 持久化,以及由一个 JSON-RPC stdio bin 提供的语义检查点。程序化客户端创建新会话;此包(package)不挂载人工交互 UI。 +ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能体)主干、客户端通过 [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md) 创建的 agent、JSONL 持久化,以及由一个 JSON-RPC stdio bin 提供的语义检查点。程序化客户端创建新会话;此包不挂载人工交互 UI。 ## 组合 diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 8edf3a43d3..ce4b65466a 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index f22147bd4a..1a8938534f 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -46,7 +46,16 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi async function composePrefix(ctx: Context): Promise<Message[]> { const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index ba1d18b7e7..3398b7296d 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 34d68b0791746c28528124853a4d8ea82b68138d -README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182 +README.md: d1c3c28183956b4f66db7ed4a6f3a08588b5352c +README.zh.md: 936a317d23bf942802ecdd87f5ea4005974e4125 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 34d68b0791..d1c3c28183 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. +- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 1b0a644595..936a317d23 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -47,7 +47,7 @@ - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、workspace context、不变式、goal 和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 82178c48de..a4b18b9c0c 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -43,6 +41,7 @@ "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks-local": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", @@ -55,6 +54,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 32e348bf75..30c51fc941 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -29,6 +29,7 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as bashEnv from '@deepseek-ai/dsh-bash-env' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -234,7 +235,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) if (config.toolBash !== false) { - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + ctx.plugin(bashEnv, { dshHome }) + ctx.plugin(toolBash, config.toolBash ?? {}) } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index bc351e0598..a524fe0630 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -39,7 +39,16 @@ declare module '@deepseek-ai/dsh-tasks' { async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> { const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } @@ -166,7 +175,6 @@ describe('dsh-agent-spine-demo bundle', () => { const session = ctx.sessions.create(SessionId('configured-title-limits')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'One two three four' }], @@ -209,8 +217,8 @@ describe('dsh-agent-spine-demo bundle', () => { it('mounts package companions and forwards invariant selection config', async () => { const nestedTurn = (ctx: Context): void => { const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) + session.append('turn/start', { turn: 2 }) } const enabled = await mount({ workspaceContext: false }) @@ -327,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'bundled project rule') - const adapter = new MockAdapter([textResponse('ok')]) + const adapter = new MockAdapter([textResponse('first')]) const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -341,9 +349,10 @@ describe('dsh-agent-spine-demo bundle', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') - expect(sentText).toContain('hi') - expect(sentText).toContain('bundled project rule') + expect(adapter.requests).toHaveLength(1) + const firstRequestText = adapter.requests[0]?.messages.map(messageText).join('\n') + expect(firstRequestText).toContain('hi') + expect(firstRequestText).toContain('bundled project rule') expect(adapter.requests[0]?.system).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') expect(adapter.requests[0]?.system).not.toContain('bundled project rule') await handle.dispose() @@ -467,9 +476,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('Use the freshly loaded body.') const transcript = handle.agent.session.events.flatMap<Record<string, unknown>>((event) => { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') { + if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, source: event.data.source, @@ -503,8 +510,14 @@ describe('dsh-agent-spine-demo bundle', () => { }, { "source": { - "kind": "plugin", - "plugin": "dsh-tool-skill", + "entries": [ + { + "description": "Hot-added skill", + "name": "hot-skill", + }, + ], + "form": "catalog", + "kind": "skill-catalog", }, "text": "<system-reminder> A skill is a reusable set of task-specific instructions. The following skills are available in this session: @@ -577,12 +590,12 @@ describe('dsh-agent-spine-demo bundle', () => { }).toThrow('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory') }) - it('places workspace instructions before the skill catalog in the session prefix', async () => { + it('delivers workspace instructions ahead of the first-step skill catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-')) try { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills') - const adapter = new MockAdapter([textResponse('ok')]) + const adapter = new MockAdapter([textResponse('first')]) const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -601,8 +614,16 @@ describe('dsh-agent-spine-demo bundle', () => { handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - expect(messageText(adapter.requests[0]?.messages[1])).toContain('workspace rule before skills') - expect(messageText(adapter.requests[0]?.messages[2])).toContain('prefix-order-skill') + expect(adapter.requests).toHaveLength(1) + const workspaceIndex = adapter.requests[0]!.messages.findIndex( + message => messageText(message).includes('workspace rule before skills'), + ) + const catalogIndex = adapter.requests[0]!.messages.findIndex( + message => messageText(message).includes('prefix-order-skill'), + ) + expect(workspaceIndex).toBeGreaterThanOrEqual(0) + expect(catalogIndex).toBeGreaterThanOrEqual(0) + expect(workspaceIndex).toBeLessThan(catalogIndex) await handle.dispose() await ctx.fiber.dispose() } finally { diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 670cd9a629..6a0091a6f6 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -68,6 +68,9 @@ { "path": "../../util/paths" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../bash/tool-bash" }, diff --git a/packages/examples/cli-demo/README.i18n.yaml b/packages/examples/cli-demo/README.i18n.yaml index f73cad16df..16eb75ae70 100644 --- a/packages/examples/cli-demo/README.i18n.yaml +++ b/packages/examples/cli-demo/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/examples/cli-demo/README.md -README.md: b8f2bde962738a1a23f0e57218ab0f90e8e0b705 -README.zh.md: 0e03375ced4e087d44eed7ff33666abf1f2cec10 +README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8 +README.zh.md: b032023fee4bf9d992217cc51731f6356f875daf diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index b8f2bde962..6e46ae8142 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. +Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin owns one idle-to-idle activity interval, renders its selected output, disposes to quiescence, and exits. The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. @@ -44,12 +44,12 @@ Loader configs resolve bare package specifiers through the optional native helpe ### Output formats - `text` writes the last assistant message containing text, followed by one newline. -- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message. -- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. +- `json` writes one DSH-native result record: `{ type: "result", sessionId, output, usage? }`. `output` is the last committed assistant text in the activity interval. `usage` sums each model step in that interval once, including billed failed attempts that produced usage without a committed assistant message. +- `stream-json` writes each canonical event from the top-level session's owned activity interval as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. -Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. +Normal idle completion exits successfully without assigning a turn reason to the task. Argument, boot, observation, and persistence failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. -The task turn is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits. +The owned activity is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits. ## Operational safety @@ -57,11 +57,11 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl ## Model Experience -### One-shot task turn +### One-shot activity #### What the model sees -The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn. +The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the owned activity. #### Token effect @@ -75,4 +75,4 @@ Tool-round history is append-only while the one-shot agent's prompt, schemas, mo - **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app. - **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy. -- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn. +- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent activity interval. diff --git a/packages/examples/cli-demo/README.zh.md b/packages/examples/cli-demo/README.zh.md index 0e03375ced..b032023fee 100644 --- a/packages/examples/cli-demo/README.zh.md +++ b/packages/examples/cli-demo/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -无头单次应用及 bin,用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent(智能体)任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 提交任务,等待其已持久化的轮次结束状态,渲染所选输出,执行 dispose(资源释放)直至完全停稳,然后退出。 +无头单次应用及 bin,用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent(智能体)任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 拥有一个从 idle 到 idle 的活动区间,渲染所选输出,执行 dispose(资源释放)直至完全停稳,然后退出。 -该包(package)不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。 +该包不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。 ## 配置 @@ -44,12 +44,12 @@ loader 配置通过仓库安装的可选原生辅助程序解析裸包说明符 ### 输出格式 - `text` 写入最后一条含文本的 assistant 消息,后跟一个换行符。 -- `json` 写入一条 DSH 原生结果记录:`{ type: "result", success, sessionId, turn, result, reason, usage? }`。`usage` 对任务轮次中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败重试。 -- `stream-json` 将顶层会话任务轮次中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。 +- `json` 写入一条 DSH 原生结果记录:`{ type: "result", sessionId, output, usage? }`。`output` 是活动区间内最后提交的 assistant 文本。`usage` 对该区间中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败尝试。 +- `stream-json` 将顶层会话自有活动区间中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。 -只有 `reason.kind === "completed"` 会成功退出。其他已持久化的轮次结束状态仍会输出部分文本或结果记录,向 stderr 添加诊断,并以非零状态退出。参数和启动失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。 +正常进入 idle 会成功退出,不会为该任务指定轮次原因。参数、启动、观测和持久化失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。 -任务轮次会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。 +自有活动会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。 ## 操作安全 @@ -57,11 +57,11 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、 ## 模型体验 -### 单次任务轮次 +### 单次活动 #### 模型看到的内容 -任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema,以及同一轮次后续步骤所需的保留工具结果。 +任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema,以及自有活动后续步骤所需的保留工具结果。 #### Token 影响 @@ -75,4 +75,4 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、 - **每个进程只创建一个新的顶层会话**:其工作区 cwd 是启动目录;此应用不支持恢复、第二条提示词、stdin 上下文或并发顶层会话。 - **没有交互式问题或批准提供方**:需要人工回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。 -- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父任务轮次记录的模型步骤。 +- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父活动区间记录的模型步骤。 diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index f977bc5986..afd129aac9 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 7dfb4b82ff..68eeadae0a 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -8,7 +8,7 @@ import { parseArgs } from 'node:util' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' const CLI_NAME = 'dsh-cli-demo' @@ -32,11 +32,8 @@ export type CliCommand = /** DSH-native final record emitted by JSON modes. */ export interface CliResult { readonly type: 'result' - readonly success: boolean readonly sessionId: string - readonly turn: number - readonly result: string - readonly reason: TurnEndReason + readonly output: string readonly usage?: TokenUsage } @@ -203,13 +200,8 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<v } /** - * Run one message-triggered turn on the configured top-level agent, aggregate its - * final text and model usage, wait for idle plus an explicit persistence flush, - * and return its durable ending. Only the selected agent's task turn reaches - * `onEvent`; startup injections and unrelated sessions are ignored. The context - * must contain exactly one top-level agent. Signal abort cancels that agent; an - * abort before the correlated task turn rejects. An observer throw cancels the - * turn and is rethrown after the agent reaches idle and the session flushes. + * Run one owned activity interval on the configured top-level agent, from the + * task's durable enqueue receipt through whole-agent idle. * @param ctx - settled Loader root containing one agent plus `ctx.sessions`. * @param options - task, optional cancellation, and optional stream observer. * @returns the DSH-native result envelope after durable quiescence. @@ -222,77 +214,49 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise } await waitForStartupIdle(agent, options.signal) - let targetTurn: number | undefined - let reason: TurnEndReason | undefined - let result = '' + const message = createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } }) + let received = false + let output = '' const usageByStep = new Map<string, TokenUsage>() let outputError: Error | undefined - let resolveTurn!: () => void - let rejectTurn!: (error: Error) => void - let firstTurnEnded = false - const turnEnded = new Promise<void>((resolve, reject) => { - resolveTurn = resolve - rejectTurn = reject - }) - - const settleResolved = (): void => { - if (firstTurnEnded) return - firstTurnEnded = true - resolveTurn() - } - const settleRejected = (error: Error): void => { - // The once-registered abort listener is the only rejecter, and a settled - // prompt makes targetTurn defined so onAbort skips rejection entirely; - // kept for symmetry with settleResolved. - /* v8 ignore next -- unreachable second settlement, see above */ - if (firstTurnEnded) return - firstTurnEnded = true - rejectTurn(error) - } + let interrupted: CliInterruptedError | undefined const observe = (sessionId: string, event: SessionEvent): void => { if (outputError !== undefined || options.onEvent === undefined) return try { options.onEvent(sessionId, event) } catch (error: unknown) { outputError = toError(error) - agent.cancel({ kind: 'user' }) + queueMicrotask(() => { + agent.cancel({ kind: 'user' }) + }) } } const disposeListener = ctx.on('session/event', (session, event) => { if (session !== agent.session) return - if (targetTurn === undefined) { - if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return - targetTurn = event.data.turn - } else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry' - && reason?.kind === 'error') { - targetTurn = event.data.turn - reason = undefined + if (!received) { + if (event.type !== 'agent/inbox/spliced' + || !event.data.inserted.some(inserted => inserted.id === message.id)) return + received = true } observe(session.id, event) - if (event.type === 'assistant/chunk' - && event.data.turn === targetTurn - && event.data.chunk.type === 'usage') { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage) } - if (event.type === 'assistant/message' && event.data.turn === targetTurn) { - result = assistantText(event) ?? result + if (event.type === 'assistant/message') { + output = assistantText(event) ?? output if (event.data.usage !== undefined) { usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage) } } - if (event.type === 'turn/end' && event.data.turn === targetTurn) { - reason = event.data.reason - settleResolved() - } }) const signal = options.signal let onAbort: (() => void) | undefined if (signal !== undefined) { onAbort = (): void => { + interrupted ??= new CliInterruptedError(interruptionReason(signal)) agent.cancel({ kind: 'user' }) - if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal))) } signal.addEventListener('abort', onAbort, { once: true }) /* v8 ignore next -- closes the race between startup-idle completion and listener registration */ @@ -300,37 +264,27 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise } try { - /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ - if (!firstTurnEnded) { // oxlint-disable-line typescript/no-unnecessary-condition - agent.followup(createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })) - } - await turnEnded + if (interrupted === undefined) agent.followup(message) + await agent.whenIdle() } finally { if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort) - await agent.whenIdle() disposeListener() } - /* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */ - if (targetTurn === undefined || reason === undefined) { - throw new Error('task ended without a correlated turn/end event') - } await ctx.sessions.flush(agent.session) if (outputError !== undefined) throw outputError + if (interrupted !== undefined) throw interrupted const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined) return { type: 'result', - success: reason.kind === 'completed', sessionId: agent.session.id, - turn: targetTurn, - result, - reason, + output, ...usage === undefined ? {} : { usage }, } } function renderResult(outputFormat: OutputFormat, result: CliResult): string { - return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n` + return outputFormat === 'text' ? `${result.output}\n` : `${JSON.stringify(result)}\n` } /** @@ -380,23 +334,6 @@ async function bootInterruptibly( } } -/** - * Render a non-completed turn reason for stderr. - * @param reason - durable turn ending to describe. - * @returns a concise diagnostic fragment. - */ -export function formatTurnFailure(reason: TurnEndReason): string { - switch (reason.kind) { - case 'completed': return 'completed' - case 'aborted': return 'was aborted' - case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` - case 'disposed': return 'was disposed' - case 'max-tokens': return 'reached the model output-token limit' - case 'interrupted': return 'was interrupted during persistence recovery' - default: return `ended with ${JSON.stringify(reason)}` - } -} - /** * Execute one CLI invocation. Argument and boot failures never write stdout; * context disposal is awaited before return, and its failure does not replace @@ -451,8 +388,7 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime = : {}, }) writeStdout(renderResult(command.outputFormat, result)) - exitCode = result.success ? 0 : 1 - if (!result.success) diagnostic = `${CLI_NAME}: turn ${result.turn} ${formatTurnFailure(result.reason)}\n` + exitCode = 0 } catch (error: unknown) { diagnostic = `${CLI_NAME}: ${toError(error).message}\n` } finally { diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 45582b5f45..c58d2657fb 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -161,14 +161,26 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task']) expect(JSON.parse(json.stdout)).toMatchObject({ - type: 'result', success: true, result: 'BUILT: json task', reason: { kind: 'completed' }, + type: 'result', output: 'BUILT: json task', usage: { inputTokens: 4, outputTokens: 2 }, }) const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task']) const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>) - expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } }) - expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' }) + expect(lines[0]).toMatchObject({ + type: 'session_event', + event: { + type: 'agent/inbox/spliced', + data: { + target: 'next-turn', + start: 0, + inserted: [{ content: [{ type: 'text', text: 'stream task' }], source: { kind: 'user' } }], + }, + }, + }) + expect(lines.findIndex(line => + (line['event'] as { type?: string } | undefined)?.type === 'turn/start')).toBeGreaterThan(0) + expect(lines.at(-1)).toMatchObject({ type: 'result', output: 'BUILT: stream task' }) const sessionsRoot = join(consumer, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const logs = files.filter(file => file.endsWith('.jsonl.zstd')) @@ -205,7 +217,7 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { ) expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null }) expect(result.stdout).toContain('"kind":"aborted"') - expect(result.stderr).toContain('turn 1 was aborted') + expect(result.stderr).toBe(`dsh-cli-demo: received ${signal}\n`) }, 30_000) }) }) diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 0a24f7dfa5..933466b2e3 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -43,7 +43,16 @@ async function mount(config: cliDemo.Config, withBash = false): Promise<Context> async function composePrefix(ctx: Context): Promise<Message[]> { const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 370379fb55..39afe0a24e 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -12,12 +12,11 @@ import { createUserMessage, type StreamChunk, type TokenUsage, } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { afterEach, describe, expect, it } from 'vitest' import * as cliDemo from '../src/index.ts' import { executeCli, - formatTurnFailure, parseCliArgs, runOneShot, type CliResult, @@ -114,14 +113,13 @@ const liveContexts: Context[] = [] async function harness(script: readonly ScriptEntry[]): Promise<Harness> { const root = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-')) - const skillHome = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-skills-')) const ctx = new Context() liveContexts.push(ctx) await ctx.plugin(cliDemo, { provider: 'mock', model: 'mock', persistenceRoot: root, - skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, + skills: { enabled: false }, workspaceContext: false, }) await new Promise(resolve => setTimeout(resolve, 80)) @@ -339,6 +337,16 @@ describe('runOneShot and executeCli', () => { expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true) }) + it('writes correlated session events in stream-json mode', async () => { + const { ctx } = await harness([textResponse('streamed answer')]) + const output = await invoke(ctx, ['--output-format', 'stream-json', 'task']) + const records = output.stdout.trim().split('\n').map(line => JSON.parse(line) as { type: string }) + + expect(output.code).toBe(0) + expect(records.some(record => record.type === 'session_event')).toBe(true) + expect(records.at(-1)).toMatchObject({ type: 'result', output: 'streamed answer' }) + }) + it('sums usage across tool steps and selects the last text-bearing assistant message', async () => { const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 } const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 } @@ -346,7 +354,7 @@ describe('runOneShot and executeCli', () => { const output = await invoke(ctx, ['--output-format', 'json', 'task']) const result = JSON.parse(output.stdout) as CliResult expect(output.code).toBe(0) - expect(result).toMatchObject({ type: 'result', success: true, turn: 1, result: 'done', reason: { kind: 'completed' } }) + expect(result).toMatchObject({ type: 'result', output: 'done' }) expect(result.usage).toEqual({ inputTokens: 17, outputTokens: 8, @@ -356,7 +364,7 @@ describe('runOneShot and executeCli', () => { }) }) - it('counts a failed retry attempt once even though it has no assistant message', async () => { + it('reports usage committed by the recovered assistant message', async () => { const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 } const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 } const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)]) @@ -364,9 +372,8 @@ describe('runOneShot and executeCli', () => { const result = await runOneShot(ctx, { task: 'task' }) expect(result.usage).toEqual({ - inputTokens: 18, - outputTokens: 7, - cacheReadTokens: 3, + inputTokens: 7, + outputTokens: 5, reasoningTokens: 4, }) }) @@ -377,38 +384,120 @@ describe('runOneShot and executeCli', () => { reasoningResponse('reasoning only'), ]) const result = await runOneShot(ctx, { task: 'task' }) - expect(result.result).toBe('working') + expect(result.output).toBe('working') }) - it('streams only the correlated main message turn and then the result envelope', async () => { - const { ctx, agent } = await harness([textResponse('streamed')]) + it('observes only the correlated main message turn', async () => { + const { ctx, agent } = await harness([ + textResponse('startup'), + textResponse('autonomous'), + textResponse('streamed'), + ]) const other = ctx.sessions.create(SessionId('unrelated')) - let injected = false - ctx.on('agent/inbox/enqueue', (subject) => { - if (subject !== agent || injected) return - injected = true - agent.inject(createUserMessage({ content: [{ type: 'text', text: 'startup injection' }], source: { kind: 'plugin', plugin: 'test' } })) - other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } }) + let startupStarted!: () => void + const started = new Promise<void>((resolve) => { startupStarted = resolve }) + const releaseStartup = Promise.withResolvers<undefined>() + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/message' + && event.data.turn === 1) startupStarted() + }) + ctx.on('agent/turn-stopping', async (subject, turn) => { + if (subject === agent && turn === 1) await releaseStartup.promise + }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'startup' }], + source: { kind: 'plugin', plugin: 'startup' }, + })) + await started + + const followup = agent.followup.bind(agent) + let injectedBeforeReceipt = false + agent.followup = (input) => { + if (!injectedBeforeReceipt && input.source.kind === 'user') { + injectedBeforeReceipt = true + agent.inbox.append('next-step', createUserMessage({ + content: [{ type: 'text', text: 'wrong receipt' }], + source: { kind: 'plugin', plugin: 'test-wrong-receipt' }, + })) + other.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unrelated session event' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'uncorrelated main-session event' }], + source: { kind: 'plugin', plugin: 'test-before-receipt' }, + }), { surfaceOp: 'append' }) + } + followup(input) + } + + let replacementQueued = false + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementQueued) return + replacementQueued = true + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'autonomous' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + other.append('turn/start', { turn: 1 }) other.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) - const output = await invoke(ctx, ['--output-format', 'stream-json', 'task']) - const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>) - const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) - expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' }) - expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } }) - expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } }) - expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) + const streamed: { sessionId: string; event: SessionEvent }[] = [] + const result = runOneShot(ctx, { + task: 'task', + onEvent: (sessionId, event) => { streamed.push({ sessionId, event }) }, + }) + releaseStartup.resolve(undefined) + + const outcome = await result + expect(outcome).toMatchObject({ type: 'result', output: 'streamed' }) + const events = streamed.map(item => item.event) + expect(events.find(event => event.type === 'turn/start')) + .toMatchObject({ type: 'turn/start', data: { turn: 3 } }) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 3 } }) + expect(streamed.every(item => item.sessionId === agent.session.id)).toBe(true) expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')).toBe(false) + expect(events.some(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'test-before-receipt')).toBe(false) }) - it('emits partial data and a diagnostic for non-completed turns', async () => { + it('correlates a task whose step history is replaced', async () => { + const { ctx } = await harness([textResponse('rewritten answer')]) + ctx.on('agent/pre-step', async () => ({ + kind: 'enter', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'rewritten task' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + await expect(runOneShot(ctx, { task: 'original task' })).resolves.toMatchObject({ + type: 'result', + output: 'rewritten answer', + }) + }) + + it('settles rejected tasks at whole-agent idle without attributing a result', async () => { + const blocked = await harness([]) + blocked.ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, + })) + await expect(runOneShot(blocked.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) + + const failed = await harness([]) + failed.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') }) + await expect(runOneShot(failed.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) + }) + + it('emits partial data without attributing a turn outcome', async () => { const { ctx } = await harness([textResponse('partial', { inputTokens: 2, outputTokens: 3 }, 'max-tokens')]) const output = await invoke(ctx, ['--output-format', 'json', 'task']) - expect(JSON.parse(output.stdout)).toMatchObject({ success: false, result: 'partial', reason: { kind: 'max-tokens' } }) - expect(output.code).toBe(1) - expect(output.stderr).toContain('output-token limit') + expect(JSON.parse(output.stdout)).toMatchObject({ type: 'result', output: 'partial' }) + expect(output.code).toBe(0) + expect(output.stderr).toBe('') }) it('cancels an active turn, emits its durable aborted result, and disposes', async () => { @@ -423,9 +512,9 @@ describe('runOneShot and executeCli', () => { await running abort.abort('received SIGINT') const output = await outcome - expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } }) + expect(output.stdout).toBe('') expect(output.code).toBe(1) - expect(output.stderr).toContain('turn 1 was aborted') + expect(output.stderr).toContain('received SIGINT') expect(agent.status).toBe('idle') }) @@ -446,6 +535,21 @@ describe('runOneShot and executeCli', () => { } as unknown as AbortSignal await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted') + const raced = await harness([textResponse('unused')]) + let registrations = 0 + const racedSignal = { + aborted: false, + reason: 'cancel before followup', + addEventListener: (_type: string, listener: () => void) => { + registrations += 1 + if (registrations === 2) listener() + }, + removeEventListener: () => {}, + } as unknown as AbortSignal + await expect(runOneShot(raced.ctx, { task: 'task', signal: racedSignal })) + .rejects.toThrow('cancel before followup') + expect(raced.agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + const preBootAbort = new AbortController() preBootAbort.abort('before boot completed') const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal }) @@ -498,27 +602,13 @@ describe('runOneShot and executeCli', () => { const queued = await harness([textResponse('unused')]) const queuedAbort = new AbortController() - queued.ctx.on('agent/inbox/enqueue', (agent) => { - if (agent === queued.agent) queuedAbort.abort('cancel queued') + queued.ctx.on('session/event', (session, event) => { + if (session === queued.agent.session && event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.source.kind === 'user')) { + queueMicrotask(() => { queuedAbort.abort('cancel queued') }) + } }) await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued') await queued.agent.whenIdle() }) }) - -describe('formatTurnFailure', () => { - it('diagnoses every durable reason and preserves merge-extensible unknowns', () => { - const cases: [TurnEndReason, string][] = [ - [{ kind: 'completed' }, 'completed'], - [{ kind: 'aborted' }, 'was aborted'], - [{ kind: 'aborted' }, 'was aborted'], - [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], - [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], - [{ kind: 'disposed' }, 'was disposed'], - [{ kind: 'max-tokens' }, 'output-token limit'], - [{ kind: 'interrupted' }, 'persistence recovery'], - ] - for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected) - expect(formatTurnFailure({ kind: 'extension' } as unknown as TurnEndReason)).toContain('extension') - }) -}) diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml index 4808b7defa..4e7935dd3a 100644 --- a/packages/examples/jsonrpc-demo/README.i18n.yaml +++ b/packages/examples/jsonrpc-demo/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/examples/jsonrpc-demo/README.md -README.md: 75e9e3943982c08e53afdbb73d1e9085b2e332bc -README.zh.md: 95a626285c2f531ff6c8cceed297b42626aed98c +README.md: 2cc496da3e22baf9e87afe4c3ca183ae3f16a5ae +README.zh.md: 98d397b3c346ee47e9a667b2ccec0179f009a05b diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index 75e9e39439..2cc496da3e 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published bin is `dsh-jsonrpc-agent`, and `lib/bin.js` also ships as the `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) used by the Python SDK. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md index 95a626285c..98d397b3c3 100644 --- a/packages/examples/jsonrpc-demo/README.zh.md +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../ui/jsonrpc/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。`lib/bin.js` 也是[单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) 的入口。 +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../ui/jsonrpc/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 bin 名为 `dsh-jsonrpc-agent`,`lib/bin.js` 还会作为 Python SDK 使用的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)交付。 ## 配置发现 @@ -12,7 +12,7 @@ ## 退出生命周期 -stdin EOF 和 `SIGTERM` 会 dispose(释放资源)根上下文,等待完全停稳后以 0 退出;`SIGINT` 完成同样的 dispose 后以 130 退出。EOF 可能按[分发 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) 所述截断正在处理的轮次。`jsonrpc` 插件拥有先响应再退出的协议关闭流程;两条路径均幂等,即使发生竞态也安全。 +stdin EOF 和 `SIGTERM` 会 dispose(释放资源)根上下文,等待完全停稳后以 0 退出;`SIGINT` 完成同样的 dispose 后以 130 退出。EOF 可能按[分发 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) 所述截断正在处理的轮次。`jsonrpc` 插件拥有先响应再退出的协议关闭流程;两条路径均幂等,即使发生竞态也安全。 ## stdout 是协议 diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index d103e3bd00..155660d190 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index fe4fcc3ecd..48cfed37ee 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/README.md README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1 -README.zh.md: df9b8cb2a91faab7af782e0f53685368e99583ff +README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18 diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index df9b8cb2a9..fc5942190a 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -1,4 +1,4 @@ -# experimental/:实验性与内部专用包(package) +# experimental/:实验性与内部专用包 [English](README.md) | 中文 diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index be9e2a7449..e435f166ad 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/README.md -README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07 -README.zh.md: ee64a617aa0d9549bcaf00b20821a6d59c6673c8 +README.md: 108d7d862a1dc6307268e2a93fa00789c952e440 +README.zh.md: 8b037cc3192bf6ceb0f0671d62911a8e035db24c diff --git a/packages/fs/README.md b/packages/fs/README.md index b5e0ac9d1c..108d7d862a 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -2,20 +2,16 @@ English | [中文](README.zh.md) -The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages. +The filesystem capability family: provider seam, interchangeable backends, policy, and model-facing tools. All **product** packages. | Package | Role | ctx key | |---|---|---| -| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | -| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) | -| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | -| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | -| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) | +| [`fs/`](fs/README.md) | Filesystem provider seam and policy-event vocabulary | `ctx.fs` | +| [`fs-local/`](fs-local/README.md) | Local-filesystem backend | registers `ctx.fs` | +| [`fs-sandbox/`](fs-sandbox/README.md) | Sandbox-enforcing backend | registers `ctx.fs` | +| [`fs-policy/`](fs-policy/README.md) | Observed-state and mutation policy | `fs/*` listeners | +| [`tool-fs/`](tool-fs/README.md) | Model-facing file tools | registers on `ctx.tools` | +| [`tool-fs-search/`](tool-fs-search/README.md) | Process-backed discovery tools | registers on `ctx.tools` | +| [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Model-facing string-replacement editor | registers on `ctx.tools` | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). - -## No timeouts on file IO - -`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries. +Backends replace one another behind `ctx.fs`; policy and tools consume the seam independently. Discovery remains process-backed instead of expanding the provider contract. Child READMEs own containment, mutation, schema, and timeout details. diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index ee64a617aa..8b037cc319 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -1,21 +1,17 @@ -# fs/:文件系统能力族 +# fs/ - 文件系统能力家族 [English](README.md) | 中文 -文件系统栈包括:提供方 seam(文本 I/O 与带可选版本防护的原子变更)、本地实现、策略门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品**包(package)。 +文件系统能力家族:提供方 seam、可互换后端、策略和面向模型工具。这些全是**产品**包。 -| 包 | 角色 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| `fs/` | 提供方 seam:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 策略事件 | `ctx.fs` | -| `fs-local/` | 本地文件系统 `FileSystem` 实现 | (注册 `ctx.fs`) | -| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根目录策略约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取则直接放行 | (注册 `ctx.fs`) | -| `fs-policy/` | 策略门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) | -| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升权字段 | (注册到 `ctx.tools`) | -| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | -| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools`) | +| [`fs/`](fs/README.md) | 文件系统提供方 seam 和策略事件词汇 | `ctx.fs` | +| [`fs-local/`](fs-local/README.md) | 本地文件系统后端 | 注册 `ctx.fs` | +| [`fs-sandbox/`](fs-sandbox/README.md) | 强制执行沙箱的后端 | 注册 `ctx.fs` | +| [`fs-policy/`](fs-policy/README.md) | 已观察状态和修改策略 | `fs/*` 监听器 | +| [`tool-fs/`](tool-fs/README.md) | 面向模型的文件工具 | 注册到 `ctx.tools` | +| [`tool-fs-search/`](tool-fs-search/README.md) | 基于进程的发现工具 | 注册到 `ctx.tools` | +| [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | 面向模型的字符串替换编辑器 | 注册到 `ctx.tools` | -接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、策略门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。策略(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它只会使策略失效,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、基于进程的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,其结果便可供后续读取,这也是其 README 所述的共置部署。 - -## 文件 I/O 不设超时 - -`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不设置截止期限。这与 bash 和 web(两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs` 由 `@deepseek-ai/dsh-timeout-policy` 强制执行):这些工作基于进程运行,截止期限可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的截止期限会成为无法兑现承诺的配置项。在此添加截止期限还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agent(Claude Code、Codex)出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。 +后端可在 `ctx.fs` 后互相替换;策略和工具独立消费该 seam。发现功能仍由进程提供,不扩展提供方契约。子 README 负责围堵、修改、schema 和超时细节。 diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 5a567ea309..cc14be584f 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7 -README.zh.md: 3d488004aa638931bcbf3a7b660ad687220101da +README.zh.md: 4c94de64561d805684f91f02d5b0dfc375f0d04f diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 3d488004aa..4c94de6456 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -14,14 +14,14 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 行为 -- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 +- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非已失效的「不存在」结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片流式读取(跨分片解码),因此超大文件无需整体保存在内存中。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)按大小决定调用哪个方法,并负责行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 - **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策略得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 - **`editText`**:在同一原语之上执行原子式的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选(OPTIONAL)的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 -包(package)根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 +包根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 ## 模型体验 diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 098de71e6f..50135f0f50 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 6690227dbc..1234e3720f 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a -README.zh.md: aa0cb25899f5906ac9f531583ba48d01ad6095b4 +README.zh.md: 499192d2623765f214af7556d23beabbe2129ea1 diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index aa0cb25899..499192d262 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -20,7 +20,7 @@ await ctx.plugin(FsPolicy) ## 四层拆分 -| 层 | 包(package) | 角色 | +| 层 | 包 | 角色 | |---|---|---| | 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 | | 策略 | `@deepseek-ai/dsh-fs-policy`(本包) | 通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑(无服务) | @@ -63,11 +63,11 @@ await ctx.plugin(FsPolicy) #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 - **已观察状态无法在会话恢复后保留**:`WeakMap` 记录的持久化工作延期处理,因此恢复的会话必须重新读取文件,才能执行防护写入/编辑。 - **没有 agent(智能体)会话的参与者绝无法满足策略**:它们的编辑会抛出 `FS_NOT_OBSERVED`,写入总会解析为 `createIfAbsent`,因此非 agent 调用方无法通过门禁覆盖现有文件。 - **直接 `ctx.fs` 读取不会发出 `fs/observed`**:在 `read` 工具之外读取的文件仍未观察;后续防护编辑会以 `FS_NOT_OBSERVED` 拒绝,直到工具读取该文件。 -- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。 +- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。 diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index e74852ef7d..634fb114f5 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index e6d4780297..35a33d135b 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md -README.md: d6070f4971e7531e929e659b6b5ed476a672dc5d -README.zh.md: c051b240163f229f7fbc583939c9f4da5095e9aa +README.md: c40fc7999ab85a70702f65a5675208163f5fc351 +README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2 diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index d6070f4971..c40fc7999a 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -39,5 +39,5 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work - **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s. -- **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift. +- **Fence-vs-runner parity is derived from one owner** — the writable set comes from `writableRoots`, shared with the Seatbelt profile; a runner profile that defines its writable set elsewhere would drift. - **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed. diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index c051b24016..15db5abbfc 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -18,7 +18,7 @@ 围栏是在可信代码中检查模型控制的路径。操作本身属于 seam(open、rename),只有目标路径不可信,因此「规范化后检查包含关系」就是该接口的完整答案。这与 `code-runtime` 的立场相同:提供约束,但不是安全边界。不可信代码的内核级隔离仍由 `ctx.bash` 负责([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md))。剩余 TOCTOU(在包含关系复查与系统调用之间替换祖先符号链接)会通过写入前立即重新规范化来缩小,并为该威胁模型所接受;内核严密边界需要 `openat2` 一类原语,其可移植性成本在此不值得。 -拒绝是结构化 `FsError`(`FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。 +拒绝是结构化 `FsError`(`FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。 ## 模型体验 @@ -39,5 +39,5 @@ ## 已知限制与暂缓事项 - **策略围栏,而非内核边界**:该检查是可信代码处理模型控制的路径,因此解析到系统调用之间残留的 TOCTOU 会被原位重新规范化缩小,但不会消除;对抗性宿主进程不在范围内。不可信代码的内核级隔离仍属于 `ctx.bash`。 -- **围栏与 runner 的一致性来自派生,而非断言**:可写集合来自 `writableRoots`,该函数与 Seatbelt profile 共享,并由一致性测试固定;不通过该函数更改可写集合的 runner profile 会发生漂移。 -- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话策略,后端用它处理无 agent 调用的回退;未组合该服务时,后端不会实施约束。 +- **围栏与 runner 的一致性由单一所有方派生**:可写集合来自 `writableRoots`,该函数与 Seatbelt profile 共享;在其他位置定义可写集合的 runner profile 会发生漂移。 +- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话策略,后端用它处理无 agent(智能体)调用的回退;未组合该服务时,后端不会实施约束。 diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 2fa4ee3f5d..08d63895d0 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index a4547d8b81..ddf57a98d0 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs/README.md -README.md: 9e6c954abad124fb2b30ebc01368a55746752013 -README.zh.md: ff97513490d4a2855564042ba0ebf9403f28289c +README.md: 6c80cc22f6f28e792c3458df3390b241b51d8202 +README.zh.md: 4772d799221efbfff9667cbc8b9e1df6af07dcff diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 9e6c954aba..6c80cc22f6 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -4,16 +4,7 @@ English | [中文](README.zh.md) The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. -This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): - -| Layer | Package | Role | -|---|---|---| -| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | -| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | -| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | -| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | - -A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change. +This package is the provider-seam layer of the [filesystem family](../README.md). The [tool](../tool-fs/README.md), [policy](../fs-policy/README.md), and [local](../fs-local/README.md) and [sandboxed](../fs-sandbox/README.md) backends remain separate consumers and implementations; the capability-seam decisions own the split ([foundation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [filesystem seam](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [provider split](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), [event gate](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)). ## Service API (`ctx.fs`) @@ -46,6 +37,10 @@ This package declares three events (see the generated [events catalog](../../../ `FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +## No IO deadline + +Filesystem primitives accept an optional `AbortSignal` but arm no deadline. Local IO is only best-effort abortable: a timeout cannot force an in-progress `fsync` or `rename` to stop, so a fixed deadline would promise control the backend cannot provide. Process-backed discovery owns its separate timeout contract. + ## Model Experience Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results. @@ -58,5 +53,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). - **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). -- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). +- **No IO deadline** — cancellation is best-effort at primitive boundaries. - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index ff97513490..4772d79922 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,18 +2,9 @@ [English](README.md) | 中文 -**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义后端提供的存储原语,包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包(package)还拥有由工具分派、策略插件监听的 `fs/*` 策略事件词汇。 +**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义后端提供的存储原语,包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、策略插件监听的 `fs/*` 策略事件词汇。 -本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): - -| 层 | 包 | 角色 | -|---|---|---| -| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 `read`/`write`/`edit` schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 | -| 策略 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) | -| 提供方 seam | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 | -| 提供方 | `@deepseek-ai/dsh-fs-local` | 宿主文件系统实现 | - -未来的沙箱化、虚拟或远程后端只需实现该接口,策略层和工具层无需改变。 +本包是[文件系统家族](../README.md)中的提供方 seam 层。[工具](../tool-fs/README.md)、[策略](../fs-policy/README.md)、[本地](../fs-local/README.md)与[沙箱化](../fs-sandbox/README.md)后端分别作为消费方与实现保持独立;能力 seam 决策负责该拆分([基础](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统 seam](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[提供方拆分](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)、[事件门禁](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md))。 ## 服务 API(`ctx.fs`) @@ -46,6 +37,10 @@ `FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整契约见 `src/types.ts`。 +## 无 I/O deadline + +文件系统原语接受可选 `AbortSignal`,但不会启动 deadline。本地 I/O 只能尽力取消:超时无法强制进行中的 `fsync` 或 `rename` 停止,因此固定 deadline 会承诺后端无法提供的控制能力。基于进程的发现功能拥有独立的超时契约。 + ## 模型体验 通过 `dsh-tool-fs` 间接产生影响;该消费方把提供方文本和错误渲染为有界且保留的文件系统工具结果。 @@ -58,5 +53,5 @@ - **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 - **只有八个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 -- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。 +- **没有 I/O deadline**:取消只能在原语边界尽力执行。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 41de454488..14989c2f77 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index a8e2222998..276db5f47c 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a -README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625 +README.zh.md: b7bb08f94682b0c85baf33ad9045320e3306bbb2 diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 42b123d5c4..b7bb08f946 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 NPM 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -20,9 +20,9 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 配置 -`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余键是可选的搜索上限,默认值如下。 +`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余配置键是可选的搜索上限,默认值如下。 -| 键 | 默认值 | 含义 | +| 配置键 | 默认值 | 含义 | |---|---|---| | `sampleOverCapGlobResults` | 无(必填) | `true` 会在顶层条目之间对超过上限的 `glob` 页面采样;`false` 保留按修改时间排序的前部。格式化 spill 成功时,两种模式都会在该产物中保留完整排序列表。 | | `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过上限的结果保持完整,并按修改时间排序。 | @@ -44,11 +44,11 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 两类预算、两类产物 -原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询;lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询;lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,Native 渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 -搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(`rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`(协作式工具超时或调用方取消)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 +搜索失败会携带由本包定义的 `SearchError`(`HarnessError` 子类),并以 `{ name, code }` 的形式呈现在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(`rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`(协作式工具超时或调用方取消)。ripgrep 的退出语义由工具负责处理:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 ## 模型体验 @@ -82,7 +82,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### KV Cache 影响 -插件作用域、采样选择与指导文本不变时前缀稳定。激活、销毁或改变选择可能使该提示词段的复用失效。 +插件作用域、采样选择与指导文本不变时前缀稳定。激活、dispose(资源释放)或改变选择可能使该提示词段的复用失效。 ### 工具 schema @@ -102,15 +102,15 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g #### 模型看到的内容 -`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line <line>: <preview>` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 与后端检索提示;否则说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面按实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面是按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样的结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。 +`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line <line>: <preview>` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 与后端检索提示,或说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面按实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面是按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样的结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。 #### Token 影响 -内联路径与匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用与保留结果在压缩前留在历史中。 +内联路径与匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用及其保留结果在压缩(compaction)前留在历史中。 #### KV Cache 影响 -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV Cache 条目失效。 ### 工具错误 @@ -124,11 +124,11 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g #### KV Cache 影响 -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV Cache 条目失效。 -## 已知局限与延期工作 +## 已知限制与暂缓事项 -- **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才保证可继续读取;本包不执行运行时跨服务校验。 +- **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才可继续读取;本包不执行运行时跨服务校验。 - **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 - **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 - **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8953aea77a..9e5b8c0374 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index fbe2e69043..292b9f395c 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md -README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5 -README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0 +README.md: 1ecfa0d013e1208b7d9058b4a254990f16f118e0 +README.zh.md: 9d4de4c219e7818c83c5ac326d2f2d385a30ece7 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a695d0ba8f..1ecfa0d013 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -150,4 +150,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. -- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). +- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../fs/README.md#no-io-deadline)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5c600ab70b..9d4de4c219 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -24,7 +24,7 @@ await ctx.plugin(ToolFs) // this package — re | `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 | | `readStreamMinSize` | `10485760` | 大于等于该大小或大小未知的文件采用流式读取,而不是整体加载到内存。 | -## 工具(schema 见[文件系统工具 schema Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) +## 工具(schema 见[文件系统工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) | 工具 | 参数 | 行为 | |---|---|---| @@ -38,7 +38,7 @@ await ctx.plugin(ToolFs) // this package — re ## 工具就是执行器;策略是事件门禁 -工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent 的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: +工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -54,7 +54,7 @@ await ctx.plugin(ToolFs) // this package — re `read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 -包(package)根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 +包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 ## 模型体验 @@ -116,7 +116,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 写入与编辑结果 @@ -130,7 +130,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 工具错误 @@ -144,10 +144,10 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 - **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 -- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 +- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../fs/README.md#no-io-deadline))。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 737f7ac26b..8af8bc77ba 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 0fc6e54ec6..b9404e20d6 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index dae1bef59b..01f15284de 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' @@ -28,20 +28,20 @@ afterEach(async () => { function agent(ctx: Context, cwd: string): Agent { const id = SessionId(`str-replace-editor-owner-${callNumber}`) const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) const value: Agent = { id, options: {}, - session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: scope.ctx, + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } ctx.agents.register(value) diff --git a/packages/goal/README.i18n.yaml b/packages/goal/README.i18n.yaml index 1a4406e288..4238bac3c4 100644 --- a/packages/goal/README.i18n.yaml +++ b/packages/goal/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/goal/README.md -README.md: f43dfd8258eabe8342207c0b1b9d6acc9e215e9f -README.zh.md: 70b38caac723d757f44109e4ec75e3c31e7c34b8 +README.md: 9fc6b0c18b1862a8be08275785ea3b6185e9bdbc +README.zh.md: 08f15bcc4e405e25d4dfd5981bbc99408833403c diff --git a/packages/goal/README.md b/packages/goal/README.md index f43dfd8258..9fc6b0c18b 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -6,9 +6,9 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| -| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | -| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — | -| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | -| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — | +| [`goal/`](goal/README.md) | Goal state and lifecycle | `ctx.goals` | +| [`goal-session/`](goal-session/README.md) | Same-session goal continuation | — | +| [`tool-goal/`](tool-goal/README.md) | Model-facing goal tools | — | +| [`command-goal/`](command-goal/README.md) | Human-facing goal command | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/README.zh.md b/packages/goal/README.zh.md index 70b38caac7..08f15bcc4e 100644 --- a/packages/goal/README.zh.md +++ b/packages/goal/README.zh.md @@ -6,9 +6,9 @@ goal 家族负责持久目标状态,与消费该状态的面向模型工具和 | 包 | 职责 | ctx 键 | |---|---|---| -| `goal/` | 事件溯源的目标生命周期、回放折叠、比较并设置变更,以及进程本地激活 | `ctx.goals` | -| `goal-session/` | 同会话 Goal Round 的准入、结果映射与生命周期竞态隔离 | 无 | -| `tool-goal/` | 面向模型的读取/创建/更新工具,并在执行时检查权限 | 无 | -| `command-goal/` | 面向用户的 `/goal` 状态,以及通过命令平面执行的生命周期控制 | 无 | +| [`goal/`](goal/README.md) | 目标状态与生命周期 | `ctx.goals` | +| [`goal-session/`](goal-session/README.md) | 同会话目标续行 | 无 | +| [`tool-goal/`](tool-goal/README.md) | 面向模型的目标工具 | 无 | +| [`command-goal/`](command-goal/README.md) | 面向用户的目标命令 | 无 | 目标状态是其所属会话日志的一部分。消费方依赖 `dsh-goal`,而不是具体的 agent loop(智能体循环);续行行为由基于公开 agent seam 的独立插件负责。 diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 5082633d83..6586a5eda8 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/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/goal/command-goal/README.md -README.md: 8e1a5b417467c8701ea935e25acfece11c5a70d4 -README.zh.md: 66f237ac7ea8ef5d158917998b0dfd49189289f1 +README.md: a02803b3ef7f93f0c4910ec2be662cc7836d9048 +README.zh.md: 9c6d0cc6e7309a138e17f1a2d5ad6c5285913394 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index 8e1a5b4174..a02803b3ef 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. +Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. ## Command contract @@ -17,7 +17,7 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. -Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin. +Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through its own durable `goal/change` event. ## Composition @@ -32,7 +32,7 @@ The producer injects `commands` and `goals`. A custom app mounts their owners pl name: '@deepseek-ai/dsh-command-goal' ``` -The TUI app enables the complete persisted-goal stack and this command by default. The ACP automation app enables the domain and model tools without mounting the command registry; `goals: false` removes that stack. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. +The shipped `dsh` base enables the persisted-goal stack and this command; the Web client provides its interactive adapter. The ACP automation app enables the domain and model tools without a command adapter; `goals: false` removes that stack. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. ## Model Experience @@ -40,19 +40,19 @@ The TUI app enables the complete persisted-goal stack and this command by defaul #### What the model sees -The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. #### Token effect -Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. #### KV Cache effect -Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix. +Command discovery, mutations, and direct output do not affect the cache. Later continuation prompts follow the driver's ordinary request history. ## Known Limitations and Deferred Work - **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic across adapters. - **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool. - **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work. -- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`. Ordinary prompts can still authorize model-facing goal tools when those are composed. +- **Web command adapter only in the shipped apps** — headless, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`. Ordinary prompts can still authorize model-facing goal tools when those are composed. diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 66f237ac7e..9c6d0cc6e7 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向用户的 `/goal` 控制,基于 [`ctx.goals`](../goal/README.md) 实现。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。[用户 goal 命令 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) 负责用户体验与组合决策。 +面向用户的 `/goal` 控制,基于 [`ctx.goals`](../goal/README.md) 实现。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现并执行它,无需模型轮次。[用户 goal 命令 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md)负责用户体验与组合决策。 ## 命令契约 @@ -17,7 +17,7 @@ 只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal,则直接返回错误,提示用户执行 edit 或 clear。 -可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;每项已接受变更都由 `dsh-goal` 持久化并提供给模型,而不是由此插件完成。 +可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更。 ## 组合 @@ -32,7 +32,7 @@ name: '@deepseek-ai/dsh-command-goal' ``` -TUI 应用默认启用完整的持久 goal 栈和此命令。ACP(Agent Client Protocol)自动化应用会启用领域与模型工具,但不挂载命令注册表;`goals: false` 会移除该栈。无 UI 的 `agent-spine-demo` 必须显式配置 `goals: {}`,避免无头单次调用方在不知情时从一个物理轮次变为包含多个 Round 的操作。 +随附 `dsh` 基础配置启用持久 goal 栈和此命令;Web 客户端提供其交互适配器。ACP(Agent Client Protocol)自动化应用启用领域与模型工具,但不挂载命令适配器;`goals: false` 会移除该栈。无 UI 的 `agent-spine-demo` 必须显式配置 `goals: {}`,避免无头单次调用方在不知情时从一个物理轮次变为包含多个 Round 的操作。 ## 模型体验 @@ -40,19 +40,19 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACP(Agent Client #### 模型看到的内容 -斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本。 +斜杠输入、变更以及直接状态/错误输出不会进入模型请求。Goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。 #### Token 影响 -读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。 #### KV Cache 影响 -命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。 +命令发现、变更与直接输出不会影响缓存。后续继续执行提示词遵循驱动器的普通请求历史。 ## 已知限制与暂缓事项 - **仅纯文本交互**:通用命令注册表没有模态编辑表单或替换确认回调;内联 edit 与显式 clear 能在不同适配器中保持明确且一致的破坏性意图。 - **没有逐命令 Round 上限参数**:`defaultMaxGoalRounds` 仍是部署配置;用户直接请求时,可以要求模型通过另行授权的 goal 工具编辑 `max_goal_rounds`。 - **没有持续状态组件**:裸 `/goal` 是可移植的观察接口;适配器专用徽标和重连后可恢复的命令输出仍属于未来 UI 工作。 -- **随附应用中只有 TUI 使用此命令**:无头 CLI(命令行界面)、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`。如果组合中包含面向模型的 goal 工具,普通提示词仍能授权它们。 +- **随附应用中只有 Web 命令适配器使用此命令**:无头、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`。如果组合中包含面向模型的 goal 工具,普通提示词仍能授权它们。 diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index b1007d79f3..7313daa4af 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 31d7f5c8d3..03bf965fd6 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' -import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandGoal from '@deepseek-ai/dsh-command-goal' interface Harness { @@ -16,34 +16,25 @@ interface Harness { readonly plugin: Awaited<ReturnType<Context['plugin']>> } -/** Append one idle injection using the public Agent contract (idle inject wraps in a one-shot injection turn, per turn enclosure). */ -function appendInjection(session: Session, input: UserMessage): void { - const lastStart = session.events.findLast(event => event.type === 'turn/start') - const turn = (lastStart?.data.turn ?? 0) + 1 - session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } }) - session.append('user/message', input, { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) -} - /** Build a live idle agent accepted by the exact-identity goal service. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { // Store-created: the command executor durably logs lifecycle events on it. const session = ctx.sessions.create(SessionId(id)) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, options: {}, session, + inbox, ctx: new Context(), get status() { return status }, - get acceptsNextStep() { return status === 'running' }, send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { appendInjection(session, input) }, - reserveTurnAdmission: () => undefined, + steer: () => {}, + inject(input) { inbox.append('next-step', input) }, cancel() { status = 'idle' }, + runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } return { agent, session } @@ -133,7 +124,7 @@ describe('/goal human command', () => { expect(created.text).toContain('Rounds: 0/256') expect(created.text).toContain('Activation: armed') expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') - expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) + expect(domainEvents(test.session).map(event => event.type)).toEqual(['goal/change']) const count = domainEvents(test.session).length await expect(run(test, ' replacement')).resolves.toEqual({ diff --git a/packages/goal/goal-session/README.i18n.yaml b/packages/goal/goal-session/README.i18n.yaml index c0c9f24f89..0c44b4b476 100644 --- a/packages/goal/goal-session/README.i18n.yaml +++ b/packages/goal/goal-session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md -README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a -README.zh.md: 4162411bf2dbeebbec8da6433c71e176057c4277 +README.md: 89413062de8cbb49d7066ec3ae42769a99c939a2 +README.zh.md: 45f61f2ea02b4a8112a88a3425ef3893128b3098 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 6a1c3b9455..89413062de 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -21,32 +21,23 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def ## Round contract -When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. -One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. +`MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint. The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. -## Settlement policy +## Idle checkpoint -| Durable turn outcome | Goal action | Automatic retry | -|---|---|---| -| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes | -| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no | -| cancellation with no goal-round attempt | keep durable phase; disarm activation | no | -| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no | -| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no | -| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | - -A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically. +At whole-agent idle, durable goal phase and revision are authoritative. An active, armed goal with capacity reserves its next round; completion, pause, blocking, and edits suppress continuation. The driver does not classify the preceding activity by correlating the goal message with `turn/end`, so provider errors and token limits are not prompt-level goal outcomes. ## Lifecycle and durability -`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start. +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A flush failure arriving through `agent/error` disarms continuation before another round can start. Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. -Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed. +Cancellation removes pending inbox work or leaves an agent-wide aborted state. At the next idle checkpoint the driver pauses a goal with a reserved or admitted attempt so cancellation cannot auto-restart it; cancellation unrelated to a goal attempt only disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels active work with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed. ## Model Experience @@ -69,5 +60,5 @@ Append-only within an epoch: each admitted round extends the existing conversati - **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred. - **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer. - **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts. -- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`. +- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent. Their session events are not attributed to the goal message or mapped into goal blocker codes. - **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy. diff --git a/packages/goal/goal-session/README.zh.md b/packages/goal/goal-session/README.zh.md index 4162411bf2..45f61f2ea0 100644 --- a/packages/goal/goal-session/README.zh.md +++ b/packages/goal/goal-session/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[`ctx.goals`](../goal/README.md) 的同会话续行驱动器。它通过公开 `Agent` 与会话 seam,把 phase 为 active 且已启用续行的目标转换为连续的 [Goal Round](../../../docs/glossary.md#goal-round);[同会话驱动器 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) 记载竞态与生命周期方面的设计理由。 +[`ctx.goals`](../goal/README.md) 的同会话续行驱动器。它通过公开 `Agent` 与会话 seam,把 phase 为 active 且已启用续行的目标转换为连续的 [Goal Round](../../../docs/glossary.md#goal-round);[同会话驱动器 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) 记载竞态与生命周期方面的设计理由。 ## 组合 @@ -21,32 +21,23 @@ ## Round 契约 -当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 +当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。`agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 -一个 Goal Round 对应一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。 +`MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。 保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。 -## 结算策略 +## Idle 检查点 -| 持久轮次结果 | Goal 操作 | 自动重试 | -|---|---|---| -| goal phase 仍为 active 且已启用续行时的 `completed` | 准入下一 Round;达到上限时以代码 `round-limit` 阻塞 | 是 | -| 已预留/准入 Goal Round 的取消,或其 `aborted` 结果 | `paused` | 否 | -| 未尝试 Goal Round 时取消 | 保留持久 phase;撤销激活 | 否 | -| `error` 且带 `RATE_LIMIT` 或 `QUOTA` | 设为 `blocked`,代码为 `usage-limited` | 否 | -| 其他 `error`、`max-tokens` 或非陈旧提示词拒绝 | 以诊断代码和消息设为 `blocked` | 否 | -| 持久性失败、dispose(资源释放)、中断或未知未来结果 | 撤销激活或阻塞,以便检查 | 否 | - -某个 goal 在自身 Round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。 +整个 agent 进入 idle 时,持久 goal phase 和 revision 具有权威性。phase 为 active、已启用续行且仍有容量的 goal 会预留下一 Round;完成、暂停、阻塞和编辑都会阻止续行。驱动器不会通过关联 goal 消息与 `turn/end` 来对前一段活动分类,因此提供方错误和 token 上限不属于提示词级 goal 结果。 ## 生命周期与持久性 -`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后停用续行,避免另一 Round 启动。 +`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。通过 `agent/error` 到达的 flush 失败会停用续行,避免另一 Round 启动。 此插件加载到现有 agent 上时绝不会继承续行启用状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新启用续行。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。 -取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。仅当取消操作所针对的是已预留或已准入的 Goal Round 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消已经准入的 Round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。 +取消会移除 inbox 中待处理的工作,或留下 agent 范围的 aborted 状态。在下一次 idle 检查点,驱动器会暂停存在已预留或已准入尝试的 goal,避免取消后自动重启;与 goal 尝试无关的取消只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消正在进行的工作,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。 ## 模型体验 @@ -67,7 +58,7 @@ ## 已知限制与暂缓事项 - **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。 -- **只在同一会话执行**:此包(package)有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。 +- **只在同一会话执行**:此包有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。 - **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 Round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 Round。 -- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。 +- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立。对应的会话事件不会归属于 goal 消息,也不会映射为 goal 阻塞代码。 - **异常情况不自动重试**:暂时性的提供方与持久化失败需要之后由用户授权 resume,而不会采用隐式重试策略。 diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 190cb795ad..86b58f3cda 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index fc66878800..b81d1cb599 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -6,24 +6,18 @@ import { isDeepStrictEqual } from 'node:util' import { FiberState } from 'cordis' import type { Context } from 'cordis' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import { classifyGoalRound } from './outcome.ts' -import type { GoalRoundOutcome } from './outcome.ts' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { renderGoalRoundPrompt } from './prompt.ts' -export { classifyGoalRound } from './outcome.ts' -export type { GoalRoundOutcome } from './outcome.ts' export { renderGoalRoundPrompt } from './prompt.ts' export const name = 'goal-session' export const inject = ['agents', 'goals', 'sessions'] -const STALE_ROUND_REASON = 'stale goal-round reservation' - /** Identity reserved before a goal continuation enters the agent inbox. */ interface RoundIdentity { readonly goalId: GoalRef['id'] @@ -31,12 +25,12 @@ interface RoundIdentity { readonly round: number } -/** One queued or admitted attempt, retained until its physical turn settles. */ +/** One queued, claimed, or admitted goal message retained until whole-agent quiescence. */ interface RoundAttempt extends RoundIdentity { + readonly messageId: MessageId readonly content: ContentBlock[] - phase: 'queued' | 'admitted' - turn: number | undefined - reason: TurnEndReason | undefined + phase: 'queued' | 'claimed' | 'admitted' + cancelled: boolean stale: boolean } @@ -44,13 +38,11 @@ interface RoundAttempt extends RoundIdentity { interface DriverState { readonly agent: Agent attempt: RoundAttempt | undefined - openTurn: number | undefined competingQueued: boolean needsCheckpoint: boolean requested: boolean run: Promise<void> | undefined stopping: boolean - readonly flushFailedTurns: Set<number> } /** Whether a source identifies an automatic, positive-numbered goal round. */ @@ -91,13 +83,11 @@ export function apply(ctx: Context): void { const state: DriverState = { agent, attempt: undefined, - openTurn: undefined, competingQueued: false, needsCheckpoint: false, requested: false, run: undefined, stopping: false, - flushFailedTurns: new Set(), } states.set(agent, state) return state @@ -133,28 +123,18 @@ export function apply(ctx: Context): void { } } - /** Apply one closed-round outcome only to the exact still-current revision. */ - function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void { - const ref = goalRef(goal) - switch (outcome.kind) { - case 'continue': - return - case 'pause': - ctx.goals.pause(state.agent, ref) - return - case 'blocked': - ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message }) - return - case 'disarm': - ctx.goals.disarm(state.agent) - return - /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ - default: - assertNever(outcome, 'goal round outcome') + /** Preserve claimed step context when this driver drops only its own round. */ + function restoreOtherClaimed(agent: Agent, messages: UserMessage[], messageId: MessageId): void { + const retained = messages.filter(message => message.id !== messageId + && !(message.source.kind === 'goal' && message.source.round === 0)) + for (const message of retained.toReversed()) { + if (agent.inbox.nextStep.some(candidate => candidate.id === message.id) + || agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) continue + agent.inbox.prepend('next-step', message) } } - /** Process a settled attempt, then reserve at most one next round. */ + /** Process admitted work at quiescence, then reserve at most one next round. */ async function drive(state: DriverState): Promise<void> { const { agent } = state if (!readyToDrive(state)) return @@ -165,8 +145,7 @@ export function apply(ctx: Context): void { await ctx.sessions.flush(agent.session) } catch (error: unknown) { ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`) - const goal = currentGoal(state) - if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' }) + disarm(state) return } // A mutation or ordinary prompt may have arrived while the checkpoint @@ -176,27 +155,7 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { - // Still unsettled: a contained turn-close failure reaches idle with the - // attempt's turn open in the log and no terminal reason recorded, so - // the drive pass must yield rather than misread it as settled. - if (attempt.reason === undefined) return state.attempt = undefined - const turn = attempt.turn - /* v8 ignore next -- a closed attempt acquired its turn at turn/start */ - if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn') - const durable = !state.flushFailedTurns.delete(turn) - const goal = currentGoal(state) - if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision - && goal.phase === 'active' && goal.activation === 'armed') { - const outcome = classifyGoalRound(attempt.reason, durable) - if (!attempt.stale) applyOutcome(state, goal, outcome) - } - if (!readyToDrive(state)) return - // The loop's persistence is eager write-behind with no turn-end flush, - // so this driver owns the round's durability barrier: checkpoint the - // settled round before reserving another (re-entering drive through - // the flush path above), disarming on failure instead of queueing an - // autonomous round on state that was never persisted. state.needsCheckpoint = true state.requested = true return @@ -214,19 +173,23 @@ export function apply(ctx: Context): void { const round = goal.roundsStarted + 1 const content = renderGoalRoundPrompt(goal, round) + const message = createUserMessage({ + content, + source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, + }) const reservation: RoundAttempt = { goalId: goal.id, revision: goal.revision, round, + messageId: message.id, content, phase: 'queued', - turn: undefined, - reason: undefined, + cancelled: false, stale: false, } state.attempt = reservation try { - agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } })) + agent.followup(message) } catch (error: unknown) { state.attempt = undefined ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) @@ -243,7 +206,7 @@ export function apply(ctx: Context): void { /** Coalesce triggers onto one agent-local serialized driver. */ function requestDrive(state: DriverState): void { - /* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */ + /* v8 ignore next -- teardown may race a final trigger after synchronously closing the step fence */ if (state.stopping) return state.requested = true if (state.run !== undefined) return @@ -277,16 +240,11 @@ export function apply(ctx: Context): void { }) } - // One composite effect owns every listener and the quiescent close. Cordis - // unloads sibling effects concurrently; nesting makes the close run first - // and keeps the admission fence installed until its drain settles. + // One composite effect keeps the step fence installed until this + // plugin's own scheduling tasks settle. ctx.effect(function* () { - /** Mark a post-turn persistence failure before idle scheduling can run. */ - ctx.on('agent/error', (agent, turn) => { + ctx.on('agent/error', (agent) => { const state = stateFor(agent) - const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn) - if (!closed) return - if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) disarm(state) }) @@ -295,96 +253,77 @@ export function apply(ctx: Context): void { ctx.on('agent/session-start', (agent) => { const state = stateFor(agent) state.attempt = undefined - state.openTurn = undefined state.competingQueued = false state.needsCheckpoint = false - state.flushFailedTurns.clear() }) ctx.on('agent/status', (agent, status) => { const state = stateFor(agent) if (status === 'idle') { state.competingQueued = false + const attempt = state.attempt + const goal = currentGoal(state) + if ((attempt?.phase === 'queued' || attempt?.phase === 'claimed' || attempt?.cancelled) + && goal?.phase === 'active' && goal.activation === 'armed') { + state.attempt = undefined + try { + ctx.goals.pause(agent, goalRef(goal)) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } requestDrive(state) } }) - ctx.on('agent/inbox/enqueue', (agent, item) => { - const state = stateFor(agent) - const attempt = state.attempt - if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return - state.competingQueued = true - if (attempt?.phase === 'queued') attempt.stale = true - }) - ctx.on('agent/cancel-requested', (agent, cause) => { - const state = stateFor(agent) - const attempt = state.attempt - state.competingQueued = false - const goal = currentGoal(state) - if (goal?.phase === 'active' && goal.activation === 'armed') { - if (attempt === undefined) { - disarm(state) - return - } - // An admitted round closes durably as aborted; retain it so the normal - // turn outcome path appends pause after cancellation reaches idle. - // Pausing here would stage context into the active outbox only for this - // same cancel() call to discard it. - if (attempt.turn !== undefined || attempt.phase === 'admitted') return - state.attempt = undefined - try { - applyOutcome(state, goal, { kind: 'pause', reason: cause.kind }) - } catch (error: unknown) { - ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) - disarm(state) - } - } - }) ctx.on('goal/changed', (agent) => { const state = stateFor(agent) state.needsCheckpoint = true requestDrive(state) }) + ctx.on('agent/inbox/inserted', (agent, { message }) => { + if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + }) + ctx.on('agent/inbox/claimed', (agent, { message }) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { + attempt.phase = 'claimed' + } + }) + ctx.on('agent/inbox/discarded', (agent, { message }) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { + attempt.cancelled = true + } + }) + ctx.on('session/event', (session: Session, event: SessionEvent) => { const agent = ctx.agents.get(session.id) if (agent === undefined || agent.session !== session) return const state = stateFor(agent) switch (event.type) { - case 'turn/start': - state.openTurn = event.data.turn - switch (event.data.trigger.kind) { - case 'message': - if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) - && sameRound(event.data.trigger.source, state.attempt)) { - state.attempt.turn = event.data.turn - } - return - case 'retry': - // A recovery policy (llm-retry) closed the round's failed turn - // and reopened its history: the attempt rides the retry turn, - // and the failed turn's provisional reason no longer settles - // the round — the retry's own outcome does. - if (state.attempt !== undefined && state.attempt.reason !== undefined - && state.attempt.reason.kind === 'error') { - state.attempt.turn = event.data.turn - state.attempt.reason = undefined - } - return - default: - // Injection and merge-extensible plugin triggers cannot admit a queued goal message. - return - } case 'user/message': - if (state.attempt !== undefined && isGoalRoundSource(event.data.source) - && sameRound(event.data.source, state.attempt)) { + if (state.attempt !== undefined && event.data.id === state.attempt.messageId) { state.attempt.phase = 'admitted' - /* v8 ignore next -- this driver's admitted message always follows its observed turn/start */ - if (state.openTurn !== undefined) state.attempt.turn = state.openTurn } return case 'turn/end': - if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason - /* v8 ignore next -- balanced live turns close the open turn just observed by this listener */ - if (state.openTurn === event.data.turn) state.openTurn = undefined + if (event.data.reason.kind === 'max-tokens') { + disarm(state) + return + } + if (event.data.reason.kind !== 'aborted') return + if (state.attempt?.phase === 'claimed' || state.attempt?.phase === 'admitted') { + state.attempt.cancelled = true + } + else disarm(state) return default: return @@ -400,22 +339,24 @@ export function apply(ctx: Context): void { const attempt = state.attempt const goal = currentGoal(state) return ctx.fiber.state === FiberState.ACTIVE - && !state.stopping && attempt !== undefined && attempt.phase === 'queued' + && !state.stopping && attempt !== undefined && attempt.phase === 'claimed' && !attempt.stale && sameQueued(content, source, attempt) && goal !== undefined && goal.id === source.goalId && goal.revision === source.revision && goal.phase === 'active' && goal.activation === 'armed' && source.round === goal.roundsStarted + 1 } - ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise<PromptDecision> => { - const { content, source } = message - if (!isGoalRoundSource(source)) return next() + ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => { + const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => + isGoalRoundSource(message.source)) + if (submitted === undefined) return next() + const { content, source } = submitted const state = stateFor(agent) let valid = false try { valid = validReservation(state, content, source) } catch (error: unknown) { - ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + ctx.logger.warn(`goal-session: pre-step check failed for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) } if (!valid) { @@ -424,33 +365,34 @@ export function apply(ctx: Context): void { attempt.stale = true state.attempt = undefined } + restoreOtherClaimed(agent, messages, submitted.id) requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON } + return { kind: 'reject' } } - let decision: PromptDecision + let decision: PreStepDecision try { decision = await next() } catch (error: unknown) { - // A throwing downstream hook drops the whole admission: the loop - // returns to idle without a turn, so a still-queued reservation would - // starve every later drive pass. Clear it and let the driver - // reschedule the round. - const attempt = state.attempt - if (attempt !== undefined && sameRound(source, attempt) && attempt.turn === undefined) { - state.attempt = undefined - requestDrive(state) - } + if (signal.aborted) throw error + // A throwing downstream hook drops the whole step proposal. Clear the + // reservation before the balanced no-step turn returns to idle so the + // next drive pass can reschedule the round. + state.attempt = undefined + requestDrive(state) throw error } - if (decision.kind === 'block') { - const attempt = state.attempt - if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined + if (signal.aborted) { + if (decision.kind === 'enter') restoreOtherClaimed(agent, decision.messages, submitted.id) + return decision + } + if (decision.kind === 'reject') { + state.attempt = undefined const goal = currentGoal(state) if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision && goal.phase === 'active' && goal.activation === 'armed') { ctx.goals.block(agent, goalRef(goal), { code: 'prompt-rejected', - message: decision.reason, + message: 'Goal round was rejected before entering its step.', }) } return decision @@ -458,18 +400,15 @@ export function apply(ctx: Context): void { try { valid = validReservation(state, content, source) } catch (error: unknown) { - ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + ctx.logger.warn(`goal-session: post-decision check failed for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) valid = false } if (!valid) { - const attempt = state.attempt - if (attempt !== undefined && sameRound(source, attempt)) { - attempt.stale = true - state.attempt = undefined - } + state.attempt = undefined + restoreOtherClaimed(agent, decision.messages, submitted.id) requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON } + return { kind: 'reject' } } return decision }) @@ -491,10 +430,11 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { attempt.stale = true - if (attempt.phase === 'admitted' && state.agent.status === 'running') { + /* v8 ignore next -- followup reserves the live agent before publishing a queued attempt */ + if (state.agent.status === 'running') { state.agent.cancel({ kind: 'parent' }) + waits.push(state.agent.whenIdle()) } - waits.push(state.agent.whenIdle()) } if (state.run !== undefined) waits.push(state.run) } diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts deleted file mode 100644 index e138bf2030..0000000000 --- a/packages/goal/goal-session/src/outcome.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** Typed settlement policy for one admitted same-session goal round. */ - -import type { TurnEndReason } from '@deepseek-ai/dsh-session' - -/** Driver action derived from one closed goal-owned turn. */ -export type GoalRoundOutcome = - | { readonly kind: 'continue' } - | { readonly kind: 'pause'; readonly reason: string } - | { - readonly kind: 'blocked' - readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'unknown-turn-outcome' - readonly message: string - } - | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } - -/** - * Classify one closed goal round without mutating goal state. - * @param reason - durable reason from the round's `turn/end`. - * @param durable - whether the closing flush reached its durability checkpoint. - * @returns the single driver action; no abnormal outcome requests an automatic retry. - */ -export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome { - if (!durable) return { kind: 'disarm', reason: 'durability-failed' } - const extensibleReason: { readonly kind: string } = reason - switch (reason.kind) { - case 'completed': - return { kind: 'continue' } - case 'aborted': - return { kind: 'pause', reason: 'cancelled' } - case 'error': { - const { code, message } = reason.failure ?? reason - return code === 'RATE_LIMIT' || code === 'QUOTA' - ? { kind: 'blocked', code: 'usage-limited', message } - : { kind: 'blocked', code: 'turn-error', message } - } - case 'max-tokens': - return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' } - case 'disposed': - return { kind: 'disarm', reason: 'disposed' } - case 'interrupted': - return { kind: 'disarm', reason: 'interrupted' } - // TurnEndReason is merge-extensible. An unknown producer cannot opt into - // automatic retry merely by adding a tag; stop for inspection instead. - default: - return { - kind: 'blocked', - code: 'unknown-turn-outcome', - message: `unknown turn outcome: ${extensibleReason.kind}`, - } - } -} diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 5a3f0bdca2..d9fd63c940 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -1,24 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import GoalService, { foldGoal, GoalId } from '@deepseek-ai/dsh-goal' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' -declare module '@deepseek-ai/dsh-session' { - interface TurnTriggerMap { - /** Test-only plugin turn with no message source. */ - 'test-metadata': { kind: 'test-metadata' } - } -} - type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) /** Small request-recording adapter with controllable failure and cancellation. */ @@ -108,6 +101,28 @@ async function harness(script: ScriptEntry[]): Promise<Harness> { return { ctx, adapter, agent, driver } } +/** Observe inserted inbox messages after the live projection accepts them. */ +function onInboxMessage( + ctx: Context, + agent: Agent, + listener: (message: UserMessage) => void, +): () => void { + return ctx.on('agent/inbox/inserted', (subject, { message }) => { + if (subject === agent) listener(message) + }) +} + +/** Observe one claimed message at its exclusive pre-step ownership transfer. */ +function onClaimedMessage( + ctx: Context, + agent: Agent, + listener: (message: UserMessage) => void, +): () => void { + return ctx.on('agent/inbox/claimed', (subject, { message }) => { + if (subject === agent) listener(message) + }) +} + /** Await a stable goal projection selected by the caller. */ async function waitForGoal( ctx: Context, @@ -128,28 +143,6 @@ async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise } describe('goal-round outcome policy', () => { - it.each([ - [{ kind: 'completed' }, true, { kind: 'continue' }], - [{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }], - [{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true, - { kind: 'blocked', code: 'usage-limited', message: 'slow down' }], - [{ kind: 'error', step: 1, failure: { message: 'credits exhausted', code: 'QUOTA' } }, true, - { kind: 'blocked', code: 'usage-limited', message: 'credits exhausted' }], - [{ kind: 'error', step: 1, failure: { message: 'provider failed', code: 'SERVER' } }, true, - { kind: 'blocked', code: 'turn-error', message: 'provider failed' }], - [{ kind: 'error', step: 1, message: 'broken' }, true, - { kind: 'blocked', code: 'turn-error', message: 'broken' }], - [{ kind: 'max-tokens' }, true, - { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }], - [{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }], - [{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }], - [{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }], - [{ kind: 'future-outcome' } as unknown as TurnEndReason, true, - { kind: 'blocked', code: 'unknown-turn-outcome', message: 'unknown turn outcome: future-outcome' }], - ] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => { - expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected) - }) - it('renders the objective, round budget, authority boundary, and completion protocol', () => { const goal: GoalView = { id: GoalId('goal-prompt'), @@ -238,39 +231,42 @@ describe('same-session goal driving', () => { }) it.each([ - ['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'], - ['request error', new Error('provider broke'), 'turn-error'], - ['max tokens', maxTokensResponse('unfinished'), 'max-tokens'], - ] as const)('stops after a %s without an automatic retry', async (_label, response, code) => { + ['rate limit', new LlmError('slow down', 'RATE_LIMIT')], + ['request error', new Error('provider broke')], + ['max tokens', maxTokensResponse('unfinished')], + ] as const)('disarms automatic continuation after a %s', async (_label, response) => { const test = await harness([response]) test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 }) - const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + const goal = await waitForGoal(test.ctx, test.agent, current => + current?.phase === 'active' && current.activation === 'disarmed') expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) - expect(goal?.blockedReason?.code).toBe(code) expect(test.adapter.requests).toHaveLength(1) }) - it('maps a downstream prompt veto to blocked without admitting the round', async () => { + it('maps a downstream step rejection to blocked without entering the round', async () => { const test = await harness([]) - test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') expect(goal?.roundsStarted).toBe(0) - expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' }) + expect(goal?.blockedReason).toEqual({ + code: 'prompt-rejected', + message: 'Goal round was rejected before entering its step.', + }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) }) - it('does not reserve again when a stopped-goal observer queues ordinary work', async () => { + it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.on('goal/changed', (agent, change) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) @@ -278,18 +274,19 @@ describe('same-session goal driving', () => { test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') - await waitForRequests(test.adapter, 1) await test.agent.whenIdle() - expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker') + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'inspect the blocker' }]) }) - it('pauses and drops a reserved round when cancellation lands before admission', async () => { + it('pauses and drops a reserved round when cancellation lands before pre-step', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.message.source.kind === 'goal') { + const cancel = onClaimedMessage(test.ctx, test.agent, (message) => { + if (message.source.kind === 'goal' && message.source.round > 0) { cancel() - agent.cancel({ kind: 'user' }) + test.agent.cancel({ kind: 'user' }) } }) test.ctx.goals.create(test.agent, { objective: 'do not start yet' }) @@ -298,8 +295,8 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) expect(test.adapter.requests).toHaveLength(0) - // No admitted continuation round (positive round); goal state changes - // (round zero) are expected in the log. + // No admitted continuation round reached the model; goal state changes are + // represented by their own durable event. expect(test.agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -314,10 +311,6 @@ describe('same-session goal driving', () => { const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) - expect(foldGoal(test.agent.session.events)).toMatchObject({ - goal: { phase: 'paused', revision: 2 }, - roundsStarted: 1, - }) expect(test.adapter.requests).toHaveLength(1) }) @@ -334,38 +327,13 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>') }) - it('ignores plugin-owned turn triggers while a goal round is queued', async () => { - const test = await harness([textResponse('goal answer')]) - const warnings: string[] = [] - test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn - let inserted = false - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return - inserted = true - const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') - const turn = (lastStart?.data.turn ?? 0) + 1 - agent.session.append('turn/start', { - turn, - trigger: { kind: 'test-metadata' }, - }) - agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - }) - test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 }) - - await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') - - expect(inserted).toBe(true) - expect(test.adapter.requests).toHaveLength(1) - expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false) - }) - it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return + onInboxMessage(test.ctx, test.agent, (message) => { + if (message.source.kind !== 'goal' || inserted) return inserted = true - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } })) + test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } })) }) test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) @@ -380,12 +348,12 @@ describe('same-session goal driving', () => { it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return + onInboxMessage(test.ctx, test.agent, (message) => { + if (message.source.kind !== 'goal' || edited) return edited = true - const current = test.ctx.goals.get(agent) + const current = test.ctx.goals.get(test.agent) if (current === undefined) throw new Error('missing goal during queued edit') - test.ctx.goals.edit(agent, current, { objective: 'new objective' }) + test.ctx.goals.edit(test.agent, current, { objective: 'new objective' }) }) test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 }) @@ -402,8 +370,8 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !edited) { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) if (current === undefined) throw new Error('missing goal during prompt edit') @@ -411,7 +379,7 @@ describe('same-session goal driving', () => { } return next() }) - test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 }) + test.ctx.goals.create(test.agent, { objective: 'edit during pre-step', maxGoalRounds: 1 }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') @@ -419,6 +387,81 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) + it('does not block a goal that downstream paused before rejecting its prompt', async () => { + const test = await harness([]) + test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) { + return next() + } + const goal = test.ctx.goals.get(agent) + if (goal === undefined) throw new Error('missing goal before downstream pause') + test.ctx.goals.pause(agent, { id: goal.id, revision: goal.revision }) + return { kind: 'reject' as const } + }) + test.ctx.goals.create(test.agent, { objective: 'pause before rejection' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ phase: 'paused' }) + expect(test.adapter.requests).toEqual([]) + }) + + it('restores non-goal step context when a claimed reservation becomes stale', async () => { + const test = await harness([textResponse('side contexts'), textResponse('revised goal')]) + const claimedContext = createUserMessage({ + content: [{ type: 'text', text: 'claimed context to restore' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const roundZeroContext = createUserMessage({ + content: [{ type: 'text', text: 'obsolete goal context' }], + source: { kind: 'goal', goalId: GoalId('old-goal'), revision: 1, round: 0 }, + }) + const queuedStepContext = createUserMessage({ + content: [{ type: 'text', text: 'context already queued for the next step' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const queuedTurnContext = createUserMessage({ + content: [{ type: 'text', text: 'context already queued for the next turn' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + let staged = false + const stopInserted = onInboxMessage(test.ctx, test.agent, (message) => { + if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return + staged = true + test.agent.inbox.prepend('next-step', claimedContext) + test.agent.inbox.prepend('next-step', roundZeroContext) + }) + let edited = false + test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + const decision = await next() + if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision + edited = true + agent.inbox.prepend('next-step', queuedStepContext) + agent.inbox.append('next-turn', queuedTurnContext) + const goal = test.ctx.goals.get(agent) + if (goal === undefined) throw new Error('missing claimed goal') + test.ctx.goals.edit(agent, goal, { objective: 'revised after claim' }) + return decision.kind === 'reject' ? decision : { + kind: 'enter' as const, + messages: [...decision.messages, queuedStepContext, queuedTurnContext], + } + }) + test.ctx.goals.create(test.agent, { objective: 'stale before admission', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + stopInserted() + + expect(goal).toMatchObject({ objective: 'revised after claim', roundsStarted: 1 }) + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore') + expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step') + expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn') + expect(requestText(test.adapter.requests[0]!)).not.toContain('obsolete goal context') + expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>') + expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim') + expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission') + }) + it('disarms without dispatch when a durability checkpoint fails', async () => { const test = await harness([]) test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) @@ -509,8 +552,8 @@ describe('same-session goal driving', () => { // attempt through cancel-requested) and THEN throws: the catch finds no // matching reservation and must not reschedule a paused goal. let fired = false - test.ctx.on('agent/prompt-submit', async (agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !fired) { + test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && !fired) { fired = true agent.cancel({ kind: 'user' }) throw new Error('hook cancelled then exploded') @@ -528,26 +571,24 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused' }) }) - it('reschedules the round when a downstream admission hook throws', async () => { - const test = await harness([textResponse('second admission succeeded')]) + it('fails closed when a downstream pre-step hook throws', async () => { + const test = await harness([]) // Registered after goal-session's own listener: the throw propagates back - // through goal-session's next() await, dropping the whole admission. + // through goal-session's next() await, dropping the whole step proposal. let threw = false - test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !threw) { + test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && !threw) { threw = true - throw new Error('downstream admission hook exploded') + throw new Error('downstream pre-step hook exploded') } return next() }) test.ctx.goals.create(test.agent, { objective: 'survive a throwing hook', maxGoalRounds: 1 }) - // The cleared reservation lets the driver reschedule; the second - // admission passes and the round completes to its limit. - const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') - expect(goal?.blockedReason?.code).toBe('round-limit') - expect(goal?.roundsStarted).toBe(1) - expect(test.adapter.requests).toHaveLength(1) + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.inbox.nextTurn).toHaveLength(0) }) it('a retry turn on a non-goal failure leaves the goal reservation untouched', async () => { @@ -657,20 +698,20 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { + it('fails an initial pre-step read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return + onClaimedMessage(test.ctx, test.agent, (message) => { + if (message.source.kind !== 'goal' || message.source.round <= 0 || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { - throw new Error('admission projection failed') + throw new Error('pre-step projection failed') }) vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => { throw 'disarm failed' }) }) - test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 }) + test.ctx.goals.create(test.agent, { objective: 'retry stale pre-step', maxGoalRounds: 1 }) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') @@ -680,8 +721,8 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => { - if (message.source.kind === 'goal' && armed) { + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('post-hook projection failed') @@ -703,7 +744,20 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + }) + + it('leaves round-zero goal context to the ordinary pre-step chain', async () => { + const test = await harness([textResponse('accepted context')]) + test.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'goal context' }], + source: { kind: 'goal', goalId: GoalId('context-goal'), revision: 1, round: 0 }, + })) + + await test.agent.whenIdle() + + expect(test.adapter.requests).toHaveLength(1) + expect(requestText(test.adapter.requests[0]!)).toContain('goal context') }) it('does not invent goal state when ordinary queued work is cancelled', async () => { @@ -736,13 +790,13 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.message.source.kind !== 'goal') return + const cancel = onInboxMessage(test.ctx, test.agent, (message) => { + if (message.source.kind !== 'goal' || message.source.round <= 0) return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { throw new Error('pause failed') }) - agent.cancel({ kind: 'user' }) + test.agent.cancel({ kind: 'user' }) }) test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' }) @@ -752,17 +806,17 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('blocks admission when downstream cancellation clears the reservation', async () => { + it('rejects the step when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !cancelled) { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) } return next() }) - test.ctx.goals.create(test.agent, { objective: 'cancel during admission' }) + test.ctx.goals.create(test.agent, { objective: 'cancel during pre-step' }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') await test.agent.whenIdle() @@ -783,15 +837,15 @@ describe('same-session goal driving', () => { activation: 'disarmed', roundsStarted: 1, }) - await test.agent.whenIdle() + expect(test.agent.status).toBe('idle') expect(test.adapter.requests).toHaveLength(1) }) it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise<void> | undefined - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) { + onInboxMessage(test.ctx, test.agent, (message) => { + if (message.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } }) @@ -821,34 +875,8 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) - it('leaves a queued reservation pending when the driver runs before its turn settles', async () => { - const test = await harness([textResponse('settled later')]) - let woken = false - test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !woken) { - woken = true - // A concurrent driver pass must observe the still-unsettled attempt - // and yield rather than double-book or clear the reservation. - agentEvents(test.ctx, test.agent).emit('agent/status', 'idle') - await new Promise<void>((resolve) => { setImmediate(resolve) }) - } - return next() - }) - test.ctx.goals.create(test.agent, { objective: 'wake early', maxGoalRounds: 1 }) - - const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') - - expect(goal?.blockedReason?.code).toBe('round-limit') - expect(goal?.roundsStarted).toBe(1) - expect(test.adapter.requests).toHaveLength(1) - }) - - it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { + it('disarms when a round turn/end cannot commit', async () => { const test = await harness([textResponse('round ran')]) - // A persistent pre-commit turn/end rejection: the loop contains the close - // failure and reaches idle, but the round's attempt holds a turn with no - // terminal reason. The idle drive pass must yield to that unsettled - // attempt rather than classify an absent reason or crash into disarm. test.ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return const event = args[1] as { type: string } @@ -859,12 +887,10 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() await new Promise((resolve) => { setImmediate(resolve) }) - // One request ran; the unsettled attempt parked the driver without a - // second reservation and without disarming the goal. expect(test.adapter.requests).toHaveLength(1) expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', - activation: 'armed', + activation: 'disarmed', }) }) @@ -903,26 +929,32 @@ describe('same-session goal driving', () => { expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) }) - it('ignores the failed outcome of a round made stale by human work queued at turn start', async () => { + it('keeps terminal agent failure disarmed and defers queued human work until another wakeup', async () => { const test = await harness([new Error('round one broke'), textResponse('human answer')]) let queued = false test.ctx.on('session/event', (session, event) => { if (session !== test.agent.session || queued) return - if (event.type === 'turn/start' && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'goal') { + if (event.type === 'user/message' && event.data.source.kind === 'goal') { queued = true - test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } })) + queueMicrotask(() => { + test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } })) + }) } }) test.ctx.goals.create(test.agent, { objective: 'survive a stale failure', maxGoalRounds: 1 }) - const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + await waitForGoal(test.ctx, test.agent, current => + current?.phase === 'active' && current.activation === 'disarmed') + + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.inbox.nextTurn).toHaveLength(1) + + test.agent.steer(createUserMessage({ content: [{ type: 'text', text: 'resume after failure' }], source: { kind: 'user' } })) + await test.agent.whenIdle() - // The stale round's turn-error never blocks the goal; only the durable - // round budget does, after the interleaved human turn ran. - expect(goal?.blockedReason?.code).toBe('round-limit') expect(test.adapter.requests).toHaveLength(2) expect(requestText(test.adapter.requests[1]!)).toContain('human interleaved') + expect(requestText(test.adapter.requests[1]!)).toContain('resume after failure') }) it('waits for work queued by a pause observer before considering the next round', async () => { @@ -950,11 +982,13 @@ describe('same-session goal driving', () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => { const test = await harness([]) let vetoed = false - test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => { - if (message.source.kind === 'goal' && !vetoed) { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) - return Promise.resolve<PromptDecision>({ kind: 'block', reason: 'cancelled by policy' }) + return Promise.resolve<PreStepDecision>({ + kind: 'reject', + }) } return next() }) @@ -970,16 +1004,16 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('awaits an unadmitted reservation stuck in admission during teardown without cancelling', async () => { + it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { const test = await harness([]) let release: (() => void) | undefined - test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => { - if (message.source.kind === 'goal' && release === undefined) { + test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + if (messages[0]?.source.kind === 'goal' && release === undefined) { await new Promise<void>((resolve) => { release = resolve }) } return next() }) - test.ctx.goals.create(test.agent, { objective: 'unload during admission' }) + test.ctx.goals.create(test.agent, { objective: 'unload during pre-step' }) await vi.waitFor(() => { expect(release).toBeDefined() }) const disposal = Promise.resolve(test.driver.dispose()) @@ -989,7 +1023,7 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) }) it('ignores session events without an exact owning agent and retires disposed agent state', async () => { @@ -997,7 +1031,6 @@ describe('same-session goal driving', () => { const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan')) orphan.append('turn/start', { turn: 1, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } }, }) orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 4303f79c20..6baaa724da 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { GoalId, - renderGoalChange, type GoalSnapshotChangeMeta, type GoalView, } from '@deepseek-ai/dsh-goal' @@ -28,30 +27,17 @@ const change: GoalSnapshotChangeMeta = { updatedAt: 1, } -const changeSource = { - kind: 'goal', - goalId: change.goal.id, - revision: change.goal.revision, - round: 0, - change, -} as const - function view(roundsStarted: number): GoalView { return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' } } function appendChange(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('user/message', createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', change) } function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void { const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content, source, }), { surfaceOp: 'append' }) @@ -82,15 +68,17 @@ describe('goal-session prompt invariants', () => { ctx.sessions.create(SessionId('goal-session-invariant-dispatch')) const userSource = { kind: 'user' } as const - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } }) + session.append('turn/start', { turn: 4 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'ordinary human message' }], source: userSource, }), { surfaceOp: 'append' }) session.append('turn/end', { turn: 4, reason: { kind: 'completed' } }) - const stateSource = { ...changeSource, round: 0 } as const - session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } }) + const stateSource = { + kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0, + } as never + session.append('turn/start', { turn: 5 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'round zero is not a driver continuation' }], @@ -114,7 +102,7 @@ describe('goal-session prompt invariants', () => { it('rejects a goal round without a reconstructable active goal', async () => { const { session } = await mount() const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ @@ -128,12 +116,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'counterfeit goal state' }], - source: changeSource, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', { ...change, extra: true } as never) appendRound(session, 2) await ctx.plugin(InvariantService, { enabled: true }) diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml index 1c724ef9c0..d918caf377 100644 --- a/packages/goal/goal/README.i18n.yaml +++ b/packages/goal/goal/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/goal/goal/README.md -README.md: 45d7ac2e1c8fbd60ab2bc8b917b326d20da02ec1 -README.zh.md: b657d1d52f75657ba44b99306e4621aa82688e96 +README.md: fc2a672c11c68ad72437251b087a274e1c4388d3 +README.zh.md: eaaae5b333151b1d936effc593b21cac515471e2 diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 45d7ac2e1c..fc2a672c11 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -21,13 +21,13 @@ Event-sourced same-session goal state. The service retains one current completio At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. -Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The model-visible `user/message` content and its typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. +Every mutation appends a durable `goal/change` event carrying the complete post-mutation snapshot; clear uses a revisioned tombstone. Goal state therefore does not depend on inbox placement, claim, admission, or discard. The session log is the only durable authority. -Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. +Strict replay derives lifecycle mutations only from `goal/change` and rejects malformed shapes, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted goal-sourced `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after the durable event commits with listener failures contained. Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. -The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal source changes, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. +The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal changes, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. ## Extension points @@ -39,15 +39,15 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve #### What the model sees -Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus. +Goal mutations do not inject model context. Tools such as `get_goal` return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path. #### Token effect -Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. +Goal mutation events add no model tokens by themselves. Tool results and scheduled continuation prompts account for their own visible state. #### KV Cache effect -Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary. +There is no KV-cache effect until another component exposes goal state in model-visible input. ## Known Limitations and Deferred Work @@ -55,4 +55,4 @@ Append-only within an epoch: each mutation follows the reusable request prefix a - **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. - **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. - **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. -- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal source data. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. +- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit `goal/change` data. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md index b657d1d52f..eaaae5b333 100644 --- a/packages/goal/goal/README.zh.md +++ b/packages/goal/goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -事件溯源的同会话目标状态。该服务在 agent(智能体)的现有会话中保留一个当前完成目标,同时将继续执行的权限作为进程本地续行启用状态。[goal 领域 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) 负责设计理由;[goal 类型目录](../../../docs/core-data-structures/goal.md)记录具体的数据形状。 +事件溯源的同会话目标状态。该服务在 agent(智能体)的现有会话中保留一个当前完成目标,同时将继续执行的权限作为进程本地续行启用状态。[goal 领域 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) 负责设计理由;[goal 类型目录](../../../docs/core-data-structures/goal.md)记录具体的数据形状。 ## 配置 @@ -21,13 +21,13 @@ 最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时,resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。 -每次非 clear 变更都会通过 `agent.inject()` 追加完整的版本化快照;clear 则追加带 revision 的 tombstone。模型可见的 `user/message` 内容与其带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。 +每次变更都会追加持久的 `goal/change` 事件,其中携带变更后的完整快照;clear 使用带 revision 的 tombstone。因此,goal 状态不依赖 inbox 放置、领取、准入或丢弃。会话日志是唯一的持久权威。 -注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision,而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`;监听器失败会被隔离处理。 +严格回放只从 `goal/change` 派生生命周期变更,并拒绝形状错误、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的已准入 Goal Round。只有来源为 goal 且已准入的 `user/message` 事件会推进正数 Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。增量回放会把游标保留在第一个损坏事件处;`goal/changed` 会在持久事件提交后触发,监听器失败会被隔离处理。 续行启用状态绝不持久化。新缓存与每次触发 `agent/session-start` 时都会停用续行,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此,会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 Round 数量,却不会启动工作;之后必须通过显式 resume 变更重新启用续行。 -单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 round。 +单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 变更、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 Round。 ## 扩展点 @@ -39,20 +39,20 @@ #### 模型看到的内容 -每项变更都是一个原始用户角色上下文块。快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`;clear 会渲染 tombstone id/revision 与 `clearedAt`。日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。 +Goal 变更不会注入模型上下文。`get_goal` 等工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。 #### Token 影响 -每项保留的变更都会向派生历史增加一份完整快照,直到压缩(compaction)将其遮蔽。完整快照让每条记录都能独立检查,但会重复目标和生命周期字段。 +Goal 变更事件本身不增加模型 token。工具结果与已调度的继续执行提示词分别计入其自身暴露的状态。 #### KV Cache 影响 -在一个 epoch 内仅追加:每项变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。 +在其他组件把 goal 状态暴露为模型可见输入之前,不会影响 KV Cache。 ## 已知限制与暂缓事项 -- **只负责状态,不负责任务调度**:此包(package)不决定已启用续行的目标何时继续,不重试异常失败,也不取消活跃轮次;这些策略属于 agent seam 消费方。 +- **只负责状态,不负责任务调度**:此包不决定已启用续行的目标何时继续,不重试异常失败,也不取消活跃轮次;这些策略属于 agent seam 消费方。 - **只有 Round 数量预算**:`maxGoalRounds` 不计量 token、货币、挂钟时间或提供方配额。 - **没有独立评估器**:记录完成或阻塞的调用方拥有最终决定权;由评估器支持的认证暂缓到独立策略层。 - **只有一个当前目标**:系统有意不支持并行目标或独立目标数据库;替换或清除后,历史仍可在会话日志中读取。 -- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 goal 来源数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。 +- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 `goal/change` 数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。 diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index eaef4e6ad2..397e5717ba 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index d7fb53e685..377ba402e2 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -35,7 +35,7 @@ export type GoalOperation = | 'block' | 'clear' -/** Full-snapshot goal mutation retained in a model-visible context event. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ export interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -55,18 +55,16 @@ export interface GoalClearChangeMeta { readonly clearedAt: number } -/** Durable change union carried by a goal-owned round-zero message source. */ +/** Durable change union carried by the goal domain's own session event. */ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ export interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } declare module '@deepseek-ai/dsh-llm' { @@ -75,6 +73,15 @@ declare module '@deepseek-ai/dsh-llm' { } } +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Complete post-mutation goal state or clear tombstone. + */ + 'goal/change': GoalChangeMeta + } +} + /** Pure replay fold of durable goal facts. */ export interface FoldedGoal { /** Current goal, absent after a clear or before the first create. */ @@ -101,7 +108,7 @@ export interface EditGoalRequest { readonly maxGoalRounds?: number } -/** Live notification after one goal mutation has been accepted for logging. */ +/** Live notification after one durable goal mutation commits. */ export interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -124,9 +131,8 @@ export type GoalErrorCode = declare module 'cordis' { interface Events { /** - * Goal mutation accepted by one live agent. The matching context event is - * already appended or queued in that agent's active tool-batch FIFO. - * Listener failures are contained. + * Goal mutation accepted by one live agent. The matching `goal/change` + * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - agent whose session owns the goal. * @param change - fresh current projection or clear tombstone. diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index ee765aaeab..6360e61a10 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -2,7 +2,6 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts' import type { @@ -14,8 +13,6 @@ import type { GoalSnapshotChangeMeta, } from './domain.ts' -type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }> - const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([ 'create', 'edit', @@ -179,7 +176,7 @@ function goalSource(source: MessageSource): GoalMessageSource | undefined { if (source.kind !== 'goal') return undefined if (typeof source.goalId !== 'string' || source.goalId.length === 0 || !Number.isSafeInteger(source.revision) || source.revision < 1 - || !Number.isSafeInteger(source.round) || source.round < 0) { + || !Number.isSafeInteger(source.round) || source.round < 1) { throw new Error('goal message source is invalid') } return source @@ -309,60 +306,21 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v } /** - * Decode and verify one model-visible goal state change without folding it. A - * goal state change is a round-zero goal-sourced `user/message` carrying the - * complete change in its source; any other user message returns `undefined`. - * A mismatched attribution, source change, or rendered body - * fails replay loudly. - * @param event - user message whose source and rendered content must agree. - * @returns validated change, or `undefined` when the message is not a goal state change. - */ -export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined { - const source = goalSource(event.data.source) - if (source === undefined) { - const [block] = event.data.content - if (block?.type === 'text' && block.text.startsWith('<goal_state>')) { - throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) - } - return undefined - } - if (source.round !== 0) return undefined - const change = decodeGoalChange(source.change) - if (change === undefined) throw new Error(`goal change at session event ${event.seq} lacks source change data`) - const ref = goalChangeRef(change) - if (source.goalId !== ref.id || source.revision !== ref.revision) { - throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) - } - if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) { - throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`) - } - return change -} - -/** - * Apply one session event and return its goal change, when present. + * Apply one session event to the strict durable goal fold. * @param state - mutable fold accumulator. * @param event - next event in sequence order. - * @returns decoded change for pending-overlay reconciliation. */ -export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { +export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void { + if (event.type === 'goal/change') { + const change = decodeGoalChange(event.data) + /* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */ + if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`) + applyGoalChange(state, change) + return + } if (event.type === 'user/message') { - // A goal state change carries a complete source change (round zero). - const change = decodeGoalEvent(event) - if (change !== undefined) { - applyGoalChange(state, change) - return change - } const source = goalSource(event.data.source) - if (source === undefined) return undefined - // A goal-sourced message without a change must be a positive-round - // admitted continuation prompt; round zero owes a durable source change. - /* v8 ignore next 3 -- decodeGoalEvent returns the change or fails loud for every - round-zero goal source, so only positive rounds reach here; the guard keeps - replay fail-loud against a decoder change */ - if (source.round === 0) { - throw new Error(`goal source at session event ${event.seq} lacks goal change data`) - } + if (source === undefined) return const current = state.goal if (current === undefined || current.phase !== 'active' || source.goalId !== current.id || source.revision !== current.revision || source.round !== state.roundsStarted + 1 @@ -371,7 +329,6 @@ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalC } state.roundsStarted = source.round } - return undefined } /** diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7ee364c130..f6a4a99fc6 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -11,19 +11,16 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { - applyGoalChange, applyGoalEvent, - decodeGoalEvent, + decodeGoalChange, emptyGoalFoldState, goalChangeRef, } from './fold.ts' import type { GoalFoldState } from './fold.ts' -import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalError, @@ -56,7 +53,6 @@ export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' -export { renderGoalChange } from './render.ts' declare module 'cordis' { interface Context { @@ -96,22 +92,22 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([ * @returns the next projection (same reference when the event is not a goal change). */ export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null { - if (event.type !== 'user/message') return state - const source = event.data.source - if (source.kind !== 'goal' || source.round !== 0) return state - const change = source.change - // Session-log data is a durable boundary: the static type promises the kind, - // but a foreign or corrupted change record must degrade to same-reference, - // never feed the zod parse in the registry drive. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard - if (change === undefined || change.kind !== 'goal/change') return state - if (change.operation === 'clear') return null - return { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, + if (event.type !== 'goal/change') return state + let change: GoalChangeMeta | undefined + try { + change = decodeGoalChange(event.data) + } catch (_invalidPersistedGoalChange) { + return state } + if (change === undefined) return state + return change.operation === 'clear' + ? null + : { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } } /** Deployment defaults for goal creation. */ @@ -126,19 +122,12 @@ export interface ResolvedConfig { defaultMaxGoalRounds: number } -/** One accepted mutation waiting to enter or be observed in the session log. */ -interface PendingGoalChange { - readonly change: GoalChangeMeta - readonly activation: GoalActivation - applied: boolean -} - -/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ +/** Process-local cache plus activation intent crossing the synchronous append boundary. */ interface GoalCache { readonly state: GoalFoldState activation: GoalActivation observedSeq: number - readonly pending: PendingGoalChange[] + pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined } /** Validated create input with every deployment default materialized. */ @@ -188,11 +177,6 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { return { code, message: message.trim() } } -/** Compare the complete canonical payloads used for deferred reconciliation. */ -function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean { - return JSON.stringify(left) === JSON.stringify(right) -} - /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ export class GoalService extends Service { static inject = ['agents'] @@ -222,7 +206,7 @@ export class GoalService extends Service { init: () => null, apply: applyGoalProjection, view: state => state, - stateVersion: 1, + stateVersion: 4, }) }) } @@ -436,34 +420,21 @@ export class GoalService extends Service { state, activation: 'disarmed', observedSeq: session.seq, - pending: [], + pendingActivation: undefined, } this.caches.set(session, cache) return cache } - /** Incrementally observe durable events without losing deferred mutations. */ + /** Incrementally observe durable events and reconcile local activation intent. */ private sync(session: Session, cache: GoalCache): void { for (const event of session.events.slice(cache.observedSeq)) { - // A goal state change is a round-zero goal-sourced user message; a - // positive round is a continuation prompt handled by applyGoalEvent. - if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) { - const change = decodeGoalEvent(event) - if (change !== undefined) { - const pending = cache.pending[0] - if (pending !== undefined && sameChange(pending.change, change)) { - if (!pending.applied) { - applyGoalChange(cache.state, change) - cache.activation = pending.activation - pending.applied = true - } - cache.pending.shift() - cache.observedSeq += 1 - continue - } - } - } applyGoalEvent(cache.state, event) + if (event.type === 'goal/change') { + cache.activation = cache.pendingActivation?.seq === event.seq + ? cache.pendingActivation.activation + : 'disarmed' + } cache.observedSeq += 1 } } @@ -555,34 +526,21 @@ export class GoalService extends Service { } this.commit(agent, cache, change, activation) const view = this.view(cache) - /* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */ + /* v8 ignore next -- the durable goal event installs the snapshot before this read */ if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly') return view } - /** Accept one mutation into the agent log/FIFO, cache, and live event stream. */ + /** Commit one mutation into the goal log, cache, and live event stream. */ private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void { const ref = goalChangeRef(change) - const pending: PendingGoalChange = { change, activation, applied: false } - cache.pending.push(pending) + cache.pendingActivation = { seq: agent.session.seq, activation } try { - agent.inject(createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change }, - })) - } catch (error: unknown) { - const index = cache.pending.indexOf(pending) - /* v8 ignore next -- a committed goal append cannot reject after its contained observers run */ - if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error }) - cache.pending.splice(index, 1) - throw error + agent.session.append('goal/change', change) + this.sync(agent.session, cache) + } finally { + cache.pendingActivation = undefined } - if (!pending.applied) { - applyGoalChange(cache.state, change) - cache.activation = activation - pending.applied = true - } - this.sync(agent.session, cache) const goal = this.view(cache) const notification: GoalChanged = { operation: change.operation, diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts deleted file mode 100644 index c269276d25..0000000000 --- a/packages/goal/goal/src/render.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Model-visible rendering for durable goal mutations. */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { GoalChangeMeta } from './domain.ts' - -/** - * Render a complete goal snapshot or clear tombstone without hidden prose. - * @param change - durable goal change carried by the message source. - * @returns the single context block logged and projected verbatim for model reconstruction. - */ -export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] { - const payload = change.operation === 'clear' - ? { cleared: change.cleared, clearedAt: change.clearedAt } - : { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, - } - return [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }] -} diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 89d85ca5b6..25e22bd5b2 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -52,7 +52,7 @@ export interface GoalSnapshot extends GoalRef { /** * The `goal` projection value: the current durable goal with its replay - * counters, exactly as the latest `goal/change` source carried them. + * counters, exactly as the latest `goal/change` event carried them. * Activation is process-local (never persisted) and deliberately absent — * the projection reflects durable phase only. */ diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index fc30a45deb..92a155c2cd 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal' +import { decodeGoalChange } from '@deepseek-ai/dsh-goal' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) @@ -44,20 +44,16 @@ describe('goal domain through a real cordis.yml and headless process', () => { const result = JSON.parse(stdout) as Record<string, unknown> expect(result).toMatchObject({ type: 'result', - success: true, }) - expect(result['result']).toBeTypeOf('string') - expect(result['result']).toContain('CLI tool round trip complete') + expect(result['output']).toBeTypeOf('string') + expect(result['output']).toContain('CLI tool round trip complete') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) - const contexts = events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'goal') - expect(contexts).toHaveLength(1) - const context = contexts[0] - if (context?.type !== 'user/message') throw new Error('expected goal context event') - const change = context.data.source.kind === 'goal' - ? decodeGoalChange(context.data.source.change) - : undefined + const changes = events.filter(event => event.type === 'goal/change') + expect(changes).toHaveLength(1) + const context = changes[0] + if (context?.type !== 'goal/change') throw new Error('expected goal change event') + const change = decodeGoalChange(context.data) if (change === undefined) throw new Error('expected durable goal change') expect(change).toMatchObject({ operation: 'create', @@ -69,10 +65,9 @@ describe('goal domain through a real cordis.yml and headless process', () => { maxGoalRounds: 7, }, }) - expect(context.data.content).toEqual(renderGoalChange(change)) expect(JSON.stringify(context)).not.toContain('activation') - // No admitted continuation round ran (the snapshot mounts without starting - // a round); the round-zero state change from create is expected above. + // No admitted continuation round ran; the goal change itself is independent + // from model-visible user messages. expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c7886fac0f..58661481cf 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,80 +1,60 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import { createUserMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import GoalService, { GoalError, GoalId, decodeGoalChange, foldGoal, - renderGoalChange, } from '@deepseek-ai/dsh-goal' -import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' - -type DeferredInjection = UserMessage +import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' interface StubAgent { agent: Agent session: Session - deferred: DeferredInjection[] - setDeferred(value: boolean): void - setStatus(value: AgentStatus): void - drain(): void } -/** Number the next balanced one-shot injection turn. */ +/** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } /** Mirror the public Agent.inject contract for domain tests. */ function appendInjection(session: Session, input: UserMessage): void { - session.append('user/message', input, { surfaceOp: 'append' }) + new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input) } /** Build a registry-compatible agent around one concrete session. */ function stubAgentForSession(session: Session): StubAgent { const id = session.id - const deferred: DeferredInjection[] = [] - let shouldDefer = false - let status: AgentStatus = 'idle' + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id, options: {}, session, + inbox, ctx: new Context(), - get status() { return status }, - get acceptsNextStep() { return status === 'running' }, + status: 'idle', send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { - if (shouldDefer) deferred.push(input) - else appendInjection(session, input) - }, - reserveTurnAdmission: () => undefined, + steer: () => {}, + inject(input) { inbox.append('next-step', input) }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } return { agent, session, - deferred, - setDeferred(value) { shouldDefer = value }, - setStatus(value) { status = value }, - drain() { - shouldDefer = false - for (const injection of deferred.splice(0)) appendInjection(session, injection) - }, } } -/** Build a registry-compatible agent with controllable context deferral. */ +/** Build a registry-compatible agent around a fresh session. */ function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent { - return stubAgentForSession(new Session(SessionId(rawId), seed)) + return stubAgentForSession(Session.create(SessionId(rawId), seed)) } async function harness(config: { defaultMaxGoalRounds?: number } = {}) { @@ -90,7 +70,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) { function appendRound(session: Session, ref: GoalRef, round: number): void { const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `round ${round}` }], source, }), { surfaceOp: 'append' }) @@ -98,7 +78,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void { } describe('GoalService creation and replay', () => { - it('applies the configured default and writes one verbatim context snapshot', async () => { + it('applies the configured default and writes one durable goal change', async () => { vi.useFakeTimers() vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) @@ -119,16 +99,15 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['user/message']) + expect(session.events.map(event => event.type)).toEqual(['goal/change']) const context = session.events[0] - expect(context?.type).toBe('user/message') - if (context?.type !== 'user/message') throw new Error('expected goal context') - expect(context.data.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) - const change = context.data.source.kind === 'goal' ? decodeGoalChange(context.data.source.change) : undefined + expect(context?.type).toBe('goal/change') + if (context?.type !== 'goal/change') throw new Error('expected durable goal change') + const change = decodeGoalChange(context.data) if (change === undefined) throw new Error('expected decoded goal change') expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) - expect(context.data.content).toEqual(renderGoalChange(change)) - expect(session.deriveMessages()).toEqual([context.data]) + expect(agent.inbox.nextStep).toEqual([]) + expect(session.deriveMessages()).toEqual([]) expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -256,7 +235,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent } = await harness() // A same-id agent backed by a different session object — the live-instance // check must reject it even though the ids match. - const impostor = stubAgentForSession(new Session(agent.id)).agent + const impostor = stubAgentForSession(Session.create(agent.id)).agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -381,25 +360,6 @@ describe('GoalService mutations', () => { expect(next.id).not.toBe(goal.id) }) - it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => { - const { ctx, agent, session } = await harness() - const seen: GoalChanged['ref'][] = [] - ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) }) - const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 }) - const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' }) - const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' }) - // GoalRef is exactly { id, revision }: every notification ref must be bare. - for (const ref of seen) { - expect(Object.keys(ref).sort()).toEqual(['id', 'revision']) - expect(ref).toEqual({ id: created.id, revision: ref.revision }) - } - expect(seen).toHaveLength(3) - // The durable fold's lastRef is the same bare ref, not a full snapshot. - const folded = foldGoal(session.events) - expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision }) - expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision']) - }) - it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { vi.useFakeTimers() vi.setSystemTime(100) @@ -411,10 +371,8 @@ describe('GoalService mutations', () => { vi.setSystemTime(80) ctx.goals.clear(agent, goal) const clear = session.events - .filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') - .map(event => event.type === 'user/message' && event.data.source.kind === 'goal' - ? decodeGoalChange(event.data.source.change) - : undefined) + .filter(event => event.type === 'goal/change') + .map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) expect(() => foldGoal(session.events)).not.toThrow() @@ -432,23 +390,15 @@ describe('GoalService mutations', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) }) - it('preserves multiple pending revisions until deferred injections enter the log', async () => { - const test = await harness() - const { ctx, agent, session, deferred } = test - test.setDeferred(true) + it('commits consecutive revisions through durable goal events', async () => { + const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 }) goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) - expect(deferred).toHaveLength(3) - expect(session.events).toHaveLength(0) - - appendInjection(session, createUserMessage({ - content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'plugin', plugin: 'test' }, - })) - expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) - test.drain() - expect(deferred).toHaveLength(0) + expect(session.events.map(event => event.type)).toEqual([ + 'goal/change', 'goal/change', 'goal/change', + ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) @@ -462,7 +412,7 @@ describe('GoalService mutations', () => { ctx.agents.register(stub.agent) let observed: ReturnType<GoalService['get']> ctx.on('session/event', (session, event) => { - if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent) + if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent) }) const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) @@ -472,36 +422,20 @@ describe('GoalService mutations', () => { expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) - it('rolls back a pending mutation when injection rejects before append', async () => { + it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const stub = stubAgent('goal-rejected-injection') - const append = stub.agent.inject.bind(stub.agent) - let reject = true - stub.agent.inject = (input) => { - if (reject) throw new Error('injection rejected') - append(input) - } + const stub = stubAgent('goal-independent-injection') + stub.agent.inject = () => { throw new Error('injection must not be called') } ctx.agents.register(stub.agent) - expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected') - reject = false - expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({ - objective: 'second attempt', + expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({ + objective: 'persist directly', revision: 1, }) - }) - - it('rejects deferred goal mutations that enter the log out of FIFO order', async () => { - const test = await harness() - test.setDeferred(true) - const created = test.ctx.goals.create(test.agent, { objective: 'ordered' }) - test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' }) - const second = test.deferred[1] - if (second === undefined) throw new Error('expected a second deferred goal mutation') - appendInjection(test.session, second) - expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal') + expect(stub.agent.inbox.nextStep).toEqual([]) + expect(stub.session.events.map(event => event.type)).toEqual(['goal/change']) }) it('observes a valid goal snapshot appended after an empty cache was established', async () => { @@ -522,13 +456,7 @@ describe('GoalService mutations', () => { createdAt: 12, updatedAt: 12, } - const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const - const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('user/message', createUserMessage({ - content: renderGoalChange(change), source, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + session.append('goal/change', change) expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, @@ -555,17 +483,8 @@ describe('GoalService mutations', () => { createdAt: 12, updatedAt: 12, } - appendInjection(session, createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change }, - })) - appendInjection(session, createUserMessage({ - content: [{ type: 'text', text: 'corrupt' }], - source: { - kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, - change: { ...change, operation: 'edit', extra: true } as never, - }, - })) + session.append('goal/change', change) + session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) expect(() => ctx.goals.get(agent)).toThrow('invalid shape') expect(() => ctx.goals.get(agent)).toThrow('invalid shape') @@ -592,30 +511,13 @@ describe('goal replay validation', () => { } } - function appendChange( - session: Session, - change: GoalChangeMeta, - overrides: { content?: ContentBlock[]; source?: MessageSource } = {}, - ): void { - const source = overrides.source ?? { - kind: 'goal', - goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id, - revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision, - round: 0, - change, - } - const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('user/message', createUserMessage({ - content: overrides.content ?? renderGoalChange(change), - source, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + function appendChange(session: Session, change: GoalChangeMeta): void { + session.append('goal/change', change) } - function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) { - const session = new Session(SessionId(`validation-${Math.random()}`)) - appendChange(session, change, overrides) + function oneChange(change: GoalChangeMeta) { + const session = Session.create(SessionId(`validation-${Math.random()}`)) + appendChange(session, change) return session.events } @@ -643,8 +545,23 @@ describe('goal replay validation', () => { } } + it('keeps durable goal state independent from inbox changes', () => { + const change = snapshotChange() + const session = Session.create(SessionId('inbox-independent-change')) + appendChange(session, change) + expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + const message = createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + inbox.append('next-step', message) + expect(inbox.remove(message.id)).toBe(true) + expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + }) + function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> { - const session = new Session(SessionId(`validation-pair-${Math.random()}`)) + const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) return foldGoal(session.events) @@ -653,7 +570,7 @@ describe('goal replay validation', () => { it('ignores unrelated metadata and non-goal round sources', () => { expect(decodeGoalChange(undefined)).toBeUndefined() expect(decodeGoalChange({ kind: 'other' })).toBeUndefined() - const session = new Session(SessionId('unrelated')) + const session = Session.create(SessionId('unrelated')) appendInjection(session, createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, @@ -661,7 +578,7 @@ describe('goal replay validation', () => { expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) @@ -671,7 +588,7 @@ describe('goal replay validation', () => { it('rejects rounds attributed to another goal', () => { const change = snapshotChange() - const session = new Session(SessionId('other-goal-round'), oneChange(change)) + const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) expect(() => foldGoal(session.events)).toThrow('not the next admitted round') }) @@ -743,7 +660,7 @@ describe('goal replay validation', () => { roundsStarted: 2, goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 }, }) - const session = new Session(SessionId('exhausted-resume')) + const session = Session.create(SessionId('exhausted-resume')) appendChange(session, base) appendRound(session, base.goal, 1) appendRound(session, base.goal, 2) @@ -769,7 +686,7 @@ describe('goal replay validation', () => { createdAt: 20, updatedAt: 20, }) - const completedSession = new Session(SessionId('reuse-complete')) + const completedSession = Session.create(SessionId('reuse-complete')) appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) @@ -781,7 +698,7 @@ describe('goal replay validation', () => { updatedAt: 20, }) const secondComplete = mutation(second, 'complete', 'complete') - const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent')) + const nonAdjacentReuse = Session.create(SessionId('reuse-non-adjacent')) appendChange(nonAdjacentReuse, base) appendChange(nonAdjacentReuse, complete) appendChange(nonAdjacentReuse, second) @@ -792,23 +709,23 @@ describe('goal replay validation', () => { const clear: GoalChangeMeta = { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, } - const clearedSession = new Session(SessionId('reuse-clear')) + const clearedSession = Session.create(SessionId('reuse-clear')) appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') }) - it('rejects goal-source context without matching durable metadata', () => { - const session = new Session(SessionId('goal-source-without-meta')) + it('rejects non-positive goal round sources', () => { + const session = Session.create(SessionId('goal-source-without-meta')) const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(() => foldGoal(session.events)).toThrow('lacks source change data') + expect(() => foldGoal(session.events)).toThrow('goal message source is invalid') }) it('rejects malformed snapshots, refs, counters, and timestamps', () => { @@ -844,24 +761,9 @@ describe('goal replay validation', () => { })).toThrow('positive safe integer') }) - it('rejects source and content drift from the durable metadata', () => { - const change = snapshotChange() - expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 }, - }))).toThrow('source is invalid') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: GoalId('goal-imposter'), revision: 1, round: 0, change }, - }))).toThrow('mismatched source attribution') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change }, - }))).toThrow('mismatched source attribution') - expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content') - }) - it('folds a clear tombstone after a snapshot', () => { const change = snapshotChange() - const session = new Session(SessionId('fold-clear'), oneChange(change)) + const session = Session.create(SessionId('fold-clear'), oneChange(change)) const clear: GoalChangeMeta = { kind: 'goal/change', version: 1, @@ -869,13 +771,7 @@ describe('goal replay validation', () => { cleared: { id: change.goal.id, revision: 2 }, clearedAt: 20, } - const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const - const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('user/message', createUserMessage({ - content: renderGoalChange(clear), source, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + appendChange(session, clear) expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: { id: change.goal.id, revision: 2 }, diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index 2e953ce1e6..b342036f83 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { GoalId, - renderGoalChange, type GoalSnapshotChangeMeta, } from '@deepseek-ai/dsh-goal' import * as GoalInvariantCompanion from '@deepseek-ai/dsh-goal/invariant' @@ -26,14 +25,6 @@ const change: GoalSnapshotChangeMeta = { updatedAt: 1, } -const changeSource = { - kind: 'goal', - goalId: change.goal.id, - revision: change.goal.revision, - round: 0, - change, -} as const - async function setup(): Promise<Context> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -46,19 +37,8 @@ describe('goal stream invariants', () => { it('accepts canonical goal snapshots and sequential admitted rounds', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('user/message', createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { - turn: 2, - trigger: { - kind: 'message', - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, - }, - }) + session.append('goal/change', change) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'continue' }], @@ -67,25 +47,18 @@ describe('goal stream invariants', () => { }).not.toThrow() }) - it('rejects model-visible drift before committing it and keeps the fold reusable', async () => { + it('rejects a malformed goal change before committing it and keeps the fold reusable', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) expect(() => { - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'counterfeit' }], - source: changeSource, - }), { surfaceOp: 'append' }) + session.append('goal/change', { ...change, extra: true } as never) }).toThrow(expect.objectContaining<Partial<InvariantError>>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-goal', })) - expect(session.seq).toBe(1) + expect(session.seq).toBe(0) expect(() => { - session.append('user/message', createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }), { surfaceOp: 'append' }) + session.append('goal/change', change) }).not.toThrow() }) @@ -93,22 +66,11 @@ describe('goal stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('user/message', createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', change) await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(GoalInvariantCompanion) - session.append('turn/start', { - turn: 2, - trigger: { - kind: 'message', - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, - }, - }) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'continue after load' }], diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 7b12d76f59..7dcab0dc40 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -10,14 +10,14 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal' +import GoalService, { applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' interface Bench { @@ -31,22 +31,22 @@ interface Bench { /** Register a minimal registry-compatible live agent over a store session. */ function liveAgent(ctx: Context, session: Session): Agent { const status: AgentStatus = 'idle' + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id: session.id, options: {}, session, + inbox, ctx, get status() { return status }, - get acceptsNextStep() { return false }, send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input: UserMessage) { - session.append('user/message', input, { surfaceOp: 'append' }) + inbox.append('next-step', input) }, - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } ctx.agents.register(agent) @@ -125,37 +125,65 @@ describe('goal projection unit', () => { } }) + it('does not let inbox changes revive a cleared goal', async () => { + const bench = await harness(true) + const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' }) + bench.ctx.goals.clear(bench.agent, created) + + bench.agent.inbox.prepend('next-step', createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + + expect(bench.tailValues().goal).toBeNull() + expect(foldGoal(bench.session.events).goal).toBeUndefined() + }) + it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => { // The package invariant rejects a violating stream loudly wherever it is // installed — the unit itself must never throw on the projection drive // (a throwing apply would tear down every registered unit's drive), so // its transition is exercised directly as the pure function it is. - const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({ + const plainUser = createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }) } as never - expect(applyGoalProjection(null, user)).toBeNull() - - const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ - content: [{ type: 'text', text: 'broken' }], - source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never, - }) } as never + }) + const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never + const empty = null + expect(applyGoalProjection(empty, user)).toBe(empty) + const queuedUser = { + type: 'agent/inbox/spliced', seq: 1, time: 2, + data: { target: 'next-step', start: 0, inserted: [plainUser] }, + } as never + const current = state + expect(applyGoalProjection(current, queuedUser)).toBe(current) + + const malformed = { + type: 'goal/change', seq: 1, time: 2, + data: { kind: 'goal/change', version: 1, operation: 'create' }, + } as never // Same-reference return: the registry's Object.is gate sees no change. - expect(applyGoalProjection(state, malformed)).toBe(state) - expect(applyGoalProjection(null, malformed)).toBeNull() + expect(applyGoalProjection(current, malformed)).toBe(current) + expect(applyGoalProjection(empty, malformed)).toBe(empty) + + const queuedRound = { + type: 'agent/inbox/spliced', seq: 3, time: 4, + data: { target: 'next-step', start: 0, inserted: [createUserMessage({ + content: [{ type: 'text', text: 'later round' }], + source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, + })] }, + } as never + expect(applyGoalProjection(current, queuedRound)).toBe(current) // A non-message event (the registry drives EVERY committed event through // apply): early same-reference return. - const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never - expect(applyGoalProjection(state, turnStart)).toBe(state) + const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never + expect(applyGoalProjection(current, turnStart)).toBe(current) - // A round-zero goal source whose change carries a foreign kind: same posture. - const foreignKind = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({ - content: [{ type: 'text', text: 'foreign' }], - source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never, - }) } as never - expect(applyGoalProjection(state, foreignKind)).toBe(state) + // A goal/change event whose payload carries a foreign kind is ignored. + const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never + expect(applyGoalProjection(current, foreignKind)).toBe(current) }) it('has no goal key when the goal service is not composed', async () => { diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 354456002b..3f4131c2b1 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/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/goal/tool-goal/README.md -README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80 -README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147 +README.md: c8c1ab84c237ee34db7abcd63962476693b5e56c +README.zh.md: 7120280fcaa8710e5bab819a0539c926ab4e424a diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 2fa80c2e5f..c8c1ab84c2 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. -An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop. +An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority @@ -61,15 +61,15 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one `<goal_complete>`/`<goal_blocked>` wrap-up instruction that asks for a grounded closing message to the user without further tool calls. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation appends the goal domain's durable `goal/change` event without queuing model context. `activation` in a result is a live observation and never becomes replay authority. #### Token effect -Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round. +Fixed schema cost plus one compact result per call. The durable mutation adds no separate model-visible context. #### KV Cache effect -Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries. +Schemas are prefix-stable while their definitions and visibility are unchanged. Calls and results append after the reusable request prefix without invalidating earlier entries. ## Known Limitations and Deferred Work diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index c6c39e3cc7..7120280fca 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[`ctx.goals`](../goal/README.md) 的面向模型控制接口:`get_goal`、`create_goal` 和 `update_goal`。[goal 工具 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md) 负责权限拆分与 Codex 风格用户体验。 +[`ctx.goals`](../goal/README.md) 的面向模型控制接口:`get_goal`、`create_goal` 和 `update_goal`。[goal 工具 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md) 负责权限拆分与 Codex 风格用户体验。 ## 工具 @@ -14,11 +14,11 @@ 3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 -自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 +自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 ## 权限 -执行要求完全相同的活跃 `exec.agent`、其继承的 `AgentRegistry` initiator、running 状态与开放轮次。create、edit、pause 和 resume 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 +执行要求完全相同的活跃 `exec.agent`、其继承的 `AgentRegistry` initiator、running 状态与开放轮次。create、edit、pause 和 resume 还要求运行时根 agent(智能体)的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 `{ kind: 'user' }` 是宿主证明。`Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配该值,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承用户权限。 @@ -61,20 +61,20 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 `<goal_complete>`/`<goal_blocked>` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会追加 goal 领域的持久 `goal/change` 事件,而不会把模型上下文排队。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。 +固定 schema 成本,加上每次调用的一条紧凑结果。持久变更不会增加单独的模型可见上下文。 #### KV Cache 影响 -schema 的定义与可见性不变时,前缀保持稳定。调用、结果和生成的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。 +schema 的定义与可见性不变时,前缀保持稳定。调用和结果会追加到可复用请求前缀之后,不会使更早条目失效。 ## 已知限制与暂缓事项 - **语义意图仍由模型判断**:执行只能证明人类直接来源,无法证明请求是否足够重大而值得创建 goal。 - **阻塞条件是否相同仍由模型判断**:运行时强制统计互不重复的已准入 Goal Round,而不判断障碍在语义上是否等价;独立评估器的实现暂缓。 - **不负责调度或直接面向人类呈现**:这些工具只变更状态;同会话驱动器与 [`dsh-command-goal`](../command-goal/README.md) 是同一领域的独立消费方。 -- **Goal Round 权限需要驱动器**:除非续行驱动器准入 goal 来源的用户轮次,否则自主 `complete`/`blocked` 路径不会启用;只挂载这个包(package)不会创建这些轮次。 +- **Goal Round 权限需要驱动器**:除非续行驱动器准入 goal 来源的用户轮次,否则自主 `complete`/`blocked` 路径不会启用;只挂载这个包不会创建这些轮次。 - **提示词注册与过滤相互独立**:某个范围可能隐藏工具,却保留指引,除非部署将两项注册限定在同一范围。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 3fcc0c61a9..c7c87e44db 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 0e878dd5c9..ad0fe9affd 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -70,8 +70,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false return execution.events.some(event => - (event.type === 'user/message' && event.data.source.kind === 'user') - || (event.type === 'steering/message' && event.data.message.source.kind === 'user')) + event.type === 'user/message' && event.data.source.kind === 'user') } /** Whether this turn is the current goal's exact admitted round. */ diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9ceed21bc9..d22ff26dc2 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -315,7 +315,12 @@ export function apply(ctx: Context, config: Config): void { content: args.action === 'complete' ? renderWrapupContext(goal.objective) : renderWrapupContext(goal.objective, args.blocked_reason as string), - source: { kind: 'plugin', plugin: 'tool-goal' }, + source: { + kind: 'plugin', + plugin: 'tool-goal', + form: 'notice', + summary: boundContextSummary(`${args.action as string}: ${goal.objective}`), + }, })) } return Promise.resolve(goalValue(goal)) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 9363081f85..c9e3ade8fe 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -21,26 +21,25 @@ interface StubAgent { setStatus(status: AgentStatus): void } -/** Build one registry-compatible live agent whose injections append in place. */ +/** Build one registry-compatible live agent whose injections enter the durable inbox. */ function stubAgent(rawId: string, supplied?: Session): StubAgent { - const session = supplied ?? new Session(SessionId(rawId)) + const session = supplied ?? Session.create(SessionId(rawId)) let status: AgentStatus = 'running' const agent: Agent = { id: session.id, options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), get status() { return status }, - get acceptsNextStep() { return status === 'running' }, ctx: new Context(), send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) + this.inbox.append('next-step', input) }, - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } return { agent, session, setStatus(value) { status = value } } @@ -51,11 +50,17 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb const turn = stub.session.events .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 - stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) - stub.session.append('user/message', createUserMessage({ + const message = createUserMessage({ content: [{ type: 'text', text }], source, - }), { surfaceOp: 'append' }) + }) + stub.agent.inbox.append('next-turn', message) + const claimed = stub.agent.inbox.claim('next-turn', turn) + if (claimed.length === 0) throw new Error('expected queued turn input') + stub.session.append('turn/start', { turn }) + for (const admitted of claimed) { + stub.session.append('user/message', admitted, { surfaceOp: 'append' }) + } return turn } @@ -253,7 +258,7 @@ describe('goal tool execution authority', () => { const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) closeTurn(root, originalTurn) const forkId = SessionId('goal-tool-resumed-fork') - const forkSession = new Session(forkId, root.session.events, { + const forkSession = Session.create(forkId, root.session.events, { version: SESSION_FORMAT_VERSION, id: forkId, createdAt: Date.now(), @@ -300,16 +305,13 @@ describe('goal tool execution authority', () => { const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'steer me' }) closeTurn(root, humanTurn) - const round = openTurn(root, { + openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1, }) - root.session.append('steering/message', { - turn: round, - message: createUserMessage({ - content: [{ type: 'text', text: 'pause now' }], - source: { kind: 'user' }, - }), - }, { surfaceOp: 'append' }) + root.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) @@ -372,7 +374,12 @@ describe('goal tool state transitions', () => { expect(complete.concludesTurn).toBeUndefined() const contexts = complete.additionalContexts ?? [] expect(contexts).toHaveLength(1) - expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + expect(contexts[0]?.source).toEqual({ + kind: 'plugin', + plugin: 'tool-goal', + form: 'notice', + summary: 'complete: pause cleanly', + }) const block = contexts[0]?.content[0] if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('<goal_complete>') diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml index c323a9b295..637a36063e 100644 --- a/packages/guard/README.i18n.yaml +++ b/packages/guard/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/guard/README.md -README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad -README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08 +README.md: 8da2f2ddcc1432e7963e600b35549f5e26121af2 +README.zh.md: aa8b5bd1f7d10d90d1dc84d07a7aad81bfb4259c diff --git a/packages/guard/README.md b/packages/guard/README.md index b7375fd2bb..8da2f2ddcc 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,10 +2,8 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins watch the agent loop for unproductive patterns and nudge the model back on course. A guard is a self-contained consumer of core seams, not a swappable capability. | Package | Role | ctx key | |---|---|---| -| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | - -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +| [`repeat-tool-guard/`](repeat-tool-guard/README.md) | Advisory reminders for repeated tool calls | listens on tool and agent events | diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md index bba5c144d0..aa8b5bd1f7 100644 --- a/packages/guard/README.zh.md +++ b/packages/guard/README.zh.md @@ -1,11 +1,9 @@ -# guard/:循环健康 guard 家族 +# guard/ — 循环卫生 guard 家族 [English](README.md) | 中文 -这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 +行为 guard 插件监视 agent loop(智能体循环)中的无效模式,并推动模型回到正轨。guard 是 core seam 的自包含消费方,而非可替换能力。 -| 包 | 职责 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) | - -提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。 +| [`repeat-tool-guard/`](repeat-tool-guard/README.md) | 针对重复工具调用的建议性提醒 | 监听工具和 agent 事件 | diff --git a/packages/guard/repeat-tool-guard/README.i18n.yaml b/packages/guard/repeat-tool-guard/README.i18n.yaml index 4863dcd147..70b644a021 100644 --- a/packages/guard/repeat-tool-guard/README.i18n.yaml +++ b/packages/guard/repeat-tool-guard/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/guard/repeat-tool-guard/README.md -README.md: 226dba10239031e8e79bd5698e77c213688ce579 -README.zh.md: fd6e291d6fe9f286d3dc3f921518f596f3749e3e +README.md: 4d7b24b1188d021a82ed87b0c721fa539a4e5f0b +README.zh.md: b332d579d10af1a55d0bf75207452ae7d15bb7c9 diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 226dba1023..4d7b24b118 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -27,17 +27,13 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. - **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap<Agent, Chain>` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap<Agent, Chain>` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/pre-step`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. -## Testing - -Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript. - ## Model Experience ### First-threshold context message diff --git a/packages/guard/repeat-tool-guard/README.zh.md b/packages/guard/repeat-tool-guard/README.zh.md index fd6e291d6f..b332d579d1 100644 --- a/packages/guard/repeat-tool-guard/README.zh.md +++ b/packages/guard/repeat-tool-guard/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是一个仅提供建议的循环中断器,而非面向模型的工具:它不会出现在工具列表中,不会否决或改写调用,只增加一种行为。它监视每个 agent(智能体)的工具调用流,统计以完全相同的规范化参数连续调用同一工具的次数;达到所配置的连续次数时,它会注入逐级增强的提示,要求模型停止重复、重新阅读上一次结果,并改用其他方案或结束任务。究竟是换一种方式重试、收集更多证据还是完成任务,仍完全由模型决定:合理的重复调用既不会延迟,也不会受阻。决策记录见 [repeat-tool-guard Agent Note(agent 决策记录)](../../../.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md)。 +这是一个仅提供建议的循环中断器,而非面向模型的工具:它不会出现在工具列表中,不会否决或改写调用,只增加一种行为。它监视每个 agent(智能体)的工具调用流,统计以完全相同的规范化参数连续调用同一工具的次数;达到所配置的连续次数时,它会注入逐级增强的提示,要求模型停止重复、重新阅读上一次结果,并改用其他方案或结束任务。究竟是换一种方式重试、收集更多证据还是完成任务,仍完全由模型决定:合理的重复调用既不会延迟,也不会受阻。决策记录见 [repeat-tool-guard Agent Note](../../../.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md)。 ## 配置 @@ -27,17 +27,13 @@ - **不受跟踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增计数器,也不重置计数器;因此,`grep X → todo_write → grep X` 仍算作连续两次 `grep X`,即使 `todo_write` 已被排除。这正是排除机制的价值:循环中穿插的记录类工具不能掩盖循环。 - **被拒绝的调用也计数。** 检测位于 `tools/post-execute`;即便调用被 `tools/pre-execute` 监听器拒绝,该事件也会运行。模型反复尝试被拒绝的调用,恰恰是需要打断的循环。 - **忽略没有 agent 的调用。** 直接调用 `ctx.tools.execute()` 的调用方没有需要提醒的模型,也没有可作为键的活跃 agent 对象。 -- **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall(瀑布式事件),因此每条链使用 `WeakMap<Agent, Chain>`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/prompt-submit`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需 dispose(资源释放)监听器。 +- **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall(瀑布式事件),因此每条链使用 `WeakMap<Agent, Chain>`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/pre-step`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需 dispose(资源释放)监听器。 - **仅驻留内存。** 从持久化恢复的会话会从一条全新的链开始:guard 是启发式提醒,并非有日志记录的不变量;提醒会延后,这是可接受的代价。 ## 提醒传递 提醒通过 post-execute 决策中的 `additionalContexts`(来源为 `{kind: 'plugin', plugin: 'repeat-tool-guard'}`)传递,绝不替换 `content`;用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后将其作为注入的 `user/message` 追加;会话会将它渲染为普通的合成用户消息。因此,提醒对模型可见、带有来源归属,并且无需增加会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并将自己的提醒放在下游决策的上下文数组之前(两种结果都适用:被阻止的调用也会收到提醒);每个条目保留自己的来源和元数据。 -## 测试 - -单元测试使用 mock 适配器(无网络)驱动真实 agent loop,并对上述链语义实现逐文件 100% 覆盖率。快照层负责 transcript(文本记录)接口:脚本化回放场景会将同一调用重复 5 次,并在 ACP(Agent Client Protocol)的 transcript 中固定两个提醒层级,即第 3 次的温和提醒和第 5 次的详细提醒;二者均为注入的 `user/message`。 - ## 模型体验 ### 首个阈值的上下文消息 @@ -58,7 +54,7 @@ You are repeating the exact same tool call with identical arguments. Carefully a #### KV Cache 影响 -仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 后续阈值的上下文消息 @@ -82,7 +78,7 @@ The repeated calls are not making progress. Do not call this tool with these exa #### KV Cache 影响 -仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index c892ca99e4..9978865a5d 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 09ebbe8bf5..f07d59e417 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -200,7 +200,10 @@ export function apply(ctx: Context, config: Config): void { const text = count === thresholds[0] ? GENTLE_REMINDER : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars)) - return createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }) + return createUserMessage({ + content: [{ type: 'text', text }], + source: { ...PLUGIN_SOURCE, form: 'notice', summary: `${exec.name} × ${count}` }, + }) } // Observe-and-enrich, never veto: count first (state advances regardless of @@ -223,8 +226,8 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/prompt-submit', (agent, _message, _signal, next): Promise<PromptDecision> => { - chains.delete(agent) + ctx.on('agent/pre-step', (agent, messages, _context, next): Promise<PreStepDecision> => { + if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) return next() }) } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index a7c31a2a09..d6858f6d1a 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -45,7 +45,14 @@ function reminders(agent: Agent): { text: string; source: unknown }[] { })) } -const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' } +// The reminder is a `notice`-form context; its summary names the repeated +// call so a reader sees it without expanding the row. +const guardSource = (tool: string, count: number) => ({ + kind: 'plugin', + plugin: 'repeat-tool-guard', + form: 'notice', + summary: `${tool} × ${count}`, +}) describe('threshold escalation', () => { it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => { @@ -62,11 +69,11 @@ describe('threshold escalation', () => { const found = reminders(agent) expect(found).toHaveLength(2) expect(found[0]!.text).toContain('repeating the exact same tool call') - expect(found[0]!.source).toEqual(GUARD_SOURCE) + expect(found[0]!.source).toEqual(guardSource('probe', 3)) expect(found[1]!.text).toContain('consecutive_calls: 5') expect(found[1]!.text).toContain('- tool: probe') expect(found[1]!.text).toContain('{"q":"same"}') - expect(found[1]!.source).toEqual(GUARD_SOURCE) + expect(found[1]!.source).toEqual(guardSource('probe', 5)) }) it('keys the gentle text to thresholds[0], not the literal 3', async () => { @@ -327,7 +334,7 @@ describe('fold onto the downstream decision', () => { expect(found[0]!.text).toBe('downstream-ctx') expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' }) expect(found[1]!.text).toContain('repeating the exact same tool call') - expect(found[1]!.source).toEqual(GUARD_SOURCE) + expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index 165722ec0d..6000d1396f 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/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/hooks/README.md -README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d +README.md: 35989292313c6cef47c02041eb671a73ff7c5786 +README.zh.md: b5187fed159b0eaf5b2bace192152e612a85bc55 diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 23478fb5e9..3598929231 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -6,8 +6,8 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | Package | Role | Shape | |---|---|---| -| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events, detached-run quiescence | library (no plugin) | -| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | -| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | +| [`hook-protocol/`](hook-protocol/README.md) | Shared shell-hook protocol library | library | +| [`hooks-claude/`](hooks-claude/README.md) | Claude Code hook bridge | plugin | +| [`hooks-codex/`](hooks-codex/README.md) | Codex hook bridge | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +The shared library owns common protocol behavior; each bridge owns its dialect-specific event mapping. The child READMEs document those contracts. diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 741300a9a3..b5187fed15 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -1,13 +1,13 @@ -# hooks/:hook 桥接 + 共享协议 +# hooks/ — 钩子桥接与共享协议 [English](README.md) | 中文 -hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent(智能体)生命周期节点扩展 agent:把桥接插件指向现有的 `hooks.json`(或 settings),即可忠实运行这些外部 shell hook。规范的扩展表层本身是 harness 的类型化拦截 seam(见[拦截 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md));「原生 hook」只是这些 seam 上的普通 Cordis 插件。这些包是把外部 shell-hook 协议转换到同一表层的**桥接**,另含它们共同依赖的共享协议格式(wire format)库。 +hooks 子系统让用户像使用 Claude Code 和 Codex 一样,在生命周期节点扩展 agent(智能体):把桥接插件指向现有 `hooks.json`(或 settings),即可忠实运行这些外部 shell 钩子。规范扩展 surface 本身是 harness 的类型化拦截 seam(参见[拦截 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md));“原生钩子”只是这些 seam 上的普通 Cordis 插件。这些包是把外部 shell 钩子协议转换到同一 surface 的**桥接**,也包括它们共同依赖的共享协议格式库。 | 包 | 职责 | 形态 | |---|---|---| -| `hook-protocol/` | 共享协议格式核心:matcher 原语、退出码/stdout codec、`runHook`(通过 `ctx.bash`)、最严格合并、`hook/*` 会话事件、分离运行完全停稳 | 库(非插件) | -| `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | -| `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | +| [`hook-protocol/`](hook-protocol/README.md) | 共享 shell 钩子协议库 | 库 | +| [`hooks-claude/`](hooks-claude/README.md) | Claude Code 钩子桥接 | 插件 | +| [`hooks-codex/`](hooks-codex/README.md) | Codex 钩子桥接 | 插件 | -Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 +共享库负责通用协议行为;各桥接负责自身方言的事件映射。子 README 记录这些契约。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index deed052066..dda161c2d7 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md -README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 -README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0 +README.md: e807d9597eb5f811195bab9a0ad2b30b545e1205 +README.zh.md: f9990c046d4e52a2fa911776e92fcf397d159633 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8cf4b95c95..e807d9597e 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -29,7 +29,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Hook provenance records must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) satisfy that owner-defined relation by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note. +Hook provenance records must sit inside an open turn. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` satisfy that owner-defined relation by construction. `SessionStart` runs before turn 1 and gets no `hook/*` record; its allowed context remains pending in the inbox until a waking delivery opens a turn — see the hooks Agent Note. ## Model Experience diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 15a537b676..f9990c046d 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 -Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 +Hook 溯源记录必须位于一个尚未结束的轮次内。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 按构造满足这条由所有者定义的关系。`SessionStart` 在轮次 1 之前运行,因此没有 `hook/*` 记录;其获准的上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次,详见 hooks Agent Note。 ## 模型体验 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index f357278db3..0e35d83513 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index e14473b3e1..6a3dd616f3 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -81,7 +81,7 @@ export type MatcherMode = 'claude' | 'codex' /** * The dialect-neutral OUTCOME a hook produced, parsed from its exit code + * stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a - * seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field + * seam-specific typed Decision (PreToolDecision, PreStepDecision, …). Every field * is OPTIONAL because a hook may exercise any subset; the bridge decides which * fields are meaningful for its hook point and which it ignores (faithful-but- * degraded — e.g. Codex ignores `allow`/`ask`). diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index f978da2645..70ecd2ce5a 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -9,7 +9,7 @@ function output(over: Partial<HookOutput> = {}): HookOutput { describe('hook/* session events', () => { it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' }) const ev = [...session.events].find(e => e.type === 'hook/invoked') @@ -22,7 +22,7 @@ describe('hook/* session events', () => { }) it('omits matcher when absent (match-all hook)', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) const ev = [...session.events].find(e => e.type === 'hook/invoked') @@ -32,7 +32,7 @@ describe('hook/* session events', () => { }) it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), @@ -43,7 +43,7 @@ describe('hook/* session events', () => { } // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys. - const session2 = new Session(SessionId('s2')) + const session2 = Session.create(SessionId('s2')) appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), @@ -57,7 +57,7 @@ describe('hook/* session events', () => { }) it('the decision falls back to stop on continue:false, else pass', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) }) appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() }) // An explicit decision wins over the continue:false fallback. @@ -70,7 +70,7 @@ describe('hook/* session events', () => { }) it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), @@ -82,7 +82,7 @@ describe('hook/* session events', () => { }) it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), @@ -94,7 +94,7 @@ describe('hook/* session events', () => { }) it('an invoked/result pair correlates by handlerId', () => { - const session = new Session(SessionId('s')) + const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 5092758e74..f1792be67f 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -30,7 +30,7 @@ const result = (overrides: Record<string, unknown> = {}) => ({ }) function startTurn(session: Session, turn = 1): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } describe('hook-protocol invariants', () => { @@ -49,7 +49,7 @@ describe('hook-protocol invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('hook/invoked', invoked()) await ctx.plugin(InvariantService) await ctx.plugin(HookInvariant) @@ -59,11 +59,11 @@ describe('hook-protocol invariants', () => { it('adopts a bare session first observed through publication', async () => { const ctx = await setup() - const session = new Session(SessionId('bare-hook-session')) + const session = Session.create(SessionId('bare-hook-session')) expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, { type: 'hook/invoked', seq: 1, time: 1, data: invoked(), diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index ed15dbf7a6..55c8aecd2b 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/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/hooks/hooks-claude/README.md -README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa -README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb +README.md: c97643832821746b816d80d498e8a66fbb9db895 +README.zh.md: 1562c99fc9d5c24ea3f303ea034a13efec022f77 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 61c2d152da..c976438328 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | CC hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (a later listener can still block/rewrite) | +| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `deny` → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision (a later outer listener can still reject/rewrite) | | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step | @@ -52,7 +52,7 @@ Every agent-scoped stdin payload carries `session_id` and string-shaped `transcr ## Context source -Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source so the durable message is never mistaken for a user prompt. ## Model Experience diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 38509ab6e6..1562c99fc9 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -4,7 +4,7 @@ 一个 Cordis 插件,在 harness 的规范拦截 seam 上运行用户现有 **Claude Code** hook 配置(`hooks.json` 或 settings 文件的 `hooks` key)中受支持的 command hook 子集。它是 hooks 子系统的 **CC 方言**部分,负责桥接中 CC 格式的逐事件 stdin payload、CC 的 env 和 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及将 hook 的中性结果映射为 harness 的类型化 Decision。方言无关原语(matcher、退出码/stdout codec、`ctx.bash` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md)。 -原生 Cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同 seam 上的原生插件(见 [拦截 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 +原生 Cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同 seam 上的原生插件(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 ## 配置 @@ -37,7 +37,7 @@ hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点, | CC hook | Harness seam | 映射 | |---|---|---| | `SessionStart` | `agent/session-start`(emit) | additionalContext → `agent.inject()` 到新会话(无法阻塞) | -| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `deny` → `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) | +| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `deny` → `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息(后续外层 listener 仍可 reject/改写) | | `PreToolUse` | `tools/pre-execute`(waterfall) | `deny` → `PreToolDecision.deny`;`ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute`(waterfall) | `deny` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | | `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 | @@ -52,7 +52,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源( ## 上下文源 -注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为用户提示词,因此桥接始终标注自身。 +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 来源,因此持久消息绝不会被误认为用户提示词。 ## 模型体验 @@ -68,7 +68,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 已阻塞提示词或工具结果 diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 4d52d1f0c5..cce1d3edb9 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 1a50c35c41..344c42e94f 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -129,8 +129,7 @@ export function apply(ctx: Context, config: Config): void { * Run every command hook configured for `point` whose matcher selects * `matchQuery`, with the per-event `payload` on stdin, and fold the results. * Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names - * an open turn. Pre-turn `UserPromptSubmit` and detached lifecycle points - * omit the pair. Returns the merged outcome (a neutral, + * an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral, * already-most-restrictive view) for the caller to map onto its seam * decision. `matchQuery` is the event's matcher subject (tool name, session * source, …); `''` for events that ignore matchers. @@ -215,22 +214,23 @@ export function apply(ctx: Context, config: Config): void { })) }) - // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no + // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => { - const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, message.content), { agent, signal }) + ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => { + if (messages.length === 0) return next() + const content = messages.flatMap(message => message.content) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) if (merged.decision === 'deny') { - return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } + return { kind: 'reject' } } - // Delegate so later listeners may still rewrite or block, then prepend our - // context only to a downstream allow decision. + // Delegate so later listeners may still rewrite or reject, then prepend our + // context only to a downstream enter decision. const downstream = await next() const ours = contextFrom(merged) - if (!ours || downstream.kind !== 'allow') return downstream + if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'allow', - ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContexts: prependContext(ours, downstream.additionalContexts), + kind: 'enter', + messages: [...downstream.messages, ours], } }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index c23625b392..1da2771257 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -89,7 +89,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-claude bridge — UserPromptSubmit', () => { - it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => { + it('a UserPromptSubmit hook that exits 2 closes a blocked turn without a step', async () => { // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks // with the reason on stderr. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) @@ -105,11 +105,11 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // The prompt was blocked before the model and before a turn opened. + // The prompt was blocked inside its turn before any model step. expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) - // Admission has no open turn in which turn-scoped hook provenance could live. - expect(events(agent).some(e => e.type === 'hook/invoked' || e.type === 'hook/result')).toBe(false) + expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' + || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => { @@ -262,11 +262,11 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // session-start fires async (detached .then → agent.inject); wait for the - // injected user/message to actually land before sending, rather than a - // fixed sleep that flakes under load. - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) + // session-start fires async (detached .then → agent.inject); injection now + // enters the next-step inbox directly and becomes a user/message only after + // step entry, so synchronize on the pending inbox item before sending. + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('project uses tabs')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -399,7 +399,9 @@ describe('hooks-claude bridge — load resilience', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked' + || event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher')) }) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 57abe9c101..f69d876d11 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -110,7 +110,7 @@ export function defineCoverageCases(group: CoverageGroup): void { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran - }) + }, 15_000) // Real agent and hook subprocess startup can exceed Vitest's default under coverage concurrency. it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { const d = dir() @@ -322,7 +322,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) + expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' + || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { @@ -495,7 +497,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, + })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -503,21 +507,25 @@ export function defineCoverageCases(group: CoverageGroup): void { // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) - expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) + expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' + || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => { - // Both the bridge hook and a later prompt-submit listener attach context; the + // Both the bridge hook and a later pre-step listener attach context; the // request must see both as separately sourced durable events. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContexts: [createUserMessage({ + ctx.on('agent/pre-step', async (_agent, messages) => ({ + kind: 'enter' as const, + messages: [{ + ...messages[0]!, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + }, createUserMessage({ content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' }, })], @@ -534,8 +542,8 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ - { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, + { kind: 'plugin', plugin: 'hooks-claude' }, ]) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 90e7f7c1dd..212192df3c 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md -README.md: e906810ed58c3d0204c618c32787af06c91cfb78 -README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 +README.md: 60dd1d098966aad6ccdb0957ee223b9843db499f +README.zh.md: 80162e9fb82eb0f9e358c3d14bd24bf4bfeddaca diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index e906810ed5..60dd1d0989 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped | Codex hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` | +| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `block` (exit 2) → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision | | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) | | `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step | @@ -56,7 +56,7 @@ Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The ## Context source -Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`). +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source so the durable message is never mistaken for a user prompt. ## Model Experience diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 4940fdb976..80162e9fb8 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -12,7 +12,7 @@ - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。 -原生 Cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 +原生 Cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 ## 配置 @@ -43,7 +43,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc | Codex hook | Harness seam | 映射 | |---|---|---| | `SessionStart` | `agent/session-start`(emit) | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` | -| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` | +| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息 | | `PreToolUse` | `tools/pre-execute`(waterfall) | `block` → `PreToolDecision.deny`(没有 `allow`/`ask`) | | `PostToolUse` | `tools/post-execute`(waterfall) | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | | `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 | @@ -56,7 +56,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc ## 上下文源 -注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 源(否则 `agent.inject()` 会将其默认为 `{ kind: 'user' }`)。 +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 来源,因此持久消息绝不会被误认为用户提示词。 ## 模型体验 @@ -72,7 +72,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 已阻塞提示词或工具结果 diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index fb598b4da3..078b58fb08 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d68e2b9d0a..e96c1a555a 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -108,7 +108,7 @@ export function apply(ctx: Context, config: Config): void { * Run and fold one configured Codex hook point. * * A supplied turn records the hook provenance pair inside that open turn. - * Pre-turn `UserPromptSubmit` and detached lifecycle points omit it. + * Detached lifecycle points omit it. */ async function runPoint( point: string, @@ -195,25 +195,29 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-end */ }) - // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. - ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => { + // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. + ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => { + if (messages.length === 0) return next() const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), - turn_id: String(lastTurn(agent) + 1), - prompt: blocksToText(message.content), + turn_id: String(turn), + prompt: blocksToText(messages.flatMap(message => message.content)), } - const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal }) + const merged = await runPoint('UserPromptSubmit', '', payload, { + agent, turn, plainStdoutAsContext: true, signal, + }) /* jscpd:ignore-start */ - if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } - // Context alone is not a veto: DELEGATE so a later prompt-submit listener can - // still block/rewrite, then fold our context onto its decision. + if (merged.decision === 'deny') { + return { kind: 'reject' } + } + // Context alone is not a veto: DELEGATE so a later pre-step listener can + // still reject/rewrite, then fold our context onto its decision. const downstream = await next() const ours = contextFrom(merged) - if (!ours || downstream.kind !== 'allow') return downstream + if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'allow', - ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContexts: prependContext(ours, downstream.additionalContexts), + kind: 'enter', + messages: [...downstream.messages, ours], } }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 3e9ae5617a..0878397edd 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -103,7 +103,7 @@ describe('hooks-codex bridge', () => { expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') - }) + }, 15_000) // Two real hook subprocesses and agent steps need startup and teardown headroom under load. it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => { const dir = configDir() @@ -125,8 +125,9 @@ describe('hooks-codex bridge', () => { expect(() => process.kill(pid, 0)).toThrow() expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) - expect(events(agent).some(event => event.type === 'hook/invoked' || event.type === 'hook/result')).toBe(false) + expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked' + || event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index fcded1394f..664f3cb2b4 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect((await capture()).payload.transcript_path).toBeNull() }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. - it('UserPromptSubmit block (exit 2) rejects admission without a turn', async () => { + it('UserPromptSubmit block (exit 2) closes a blocked turn without a step', async () => { const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) @@ -89,7 +89,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) + expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' + || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { @@ -109,12 +111,16 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, + })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) - expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) + expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' + || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type)) + .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end']) }) it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => { @@ -122,10 +128,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContexts: [createUserMessage({ + ctx.on('agent/pre-step', async (_agent, messages) => ({ + kind: 'enter' as const, + messages: [{ + ...messages[0]!, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + }, createUserMessage({ content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' }, })], @@ -138,8 +146,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect(req).toContain('rewritten-prompt') const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ - { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, + { kind: 'plugin', plugin: 'hooks-codex' }, ]) }) }) @@ -195,7 +203,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true) expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) + }, 10_000) // The real hook subprocess needs startup and teardown headroom under full-suite contention. it('SessionStart additionalContext is injected for the first request', async () => { const d = dir() @@ -203,8 +211,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('start-ctx')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') }) @@ -543,8 +551,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('session preamble')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') }) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 8ac2454dfa..178db5dcef 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: 391adb7009a01d1ec95c8dcb809e8a2065aa0b31 -README.zh.md: 7fc730ed9ec3a067589b277733eb5bb2c42f8b4e +README.md: 7cd331f113eeec6c0a56f0ebc60554d9647aee75 +README.zh.md: 07b0e1569e17b9f0465a43f77fa2dbddcb1bae91 diff --git a/packages/host/README.md b/packages/host/README.md index 391adb7009..7cd331f113 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -6,11 +6,11 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | Package | Role | ctx key | |---|---|---| -| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` | -| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | -| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | -| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) | -| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) | -| `directory-picker-auto/` | Adaptive chooser: resolves the host's situation once at boot (bind host, SSH, display) and mounts the matching dual-face backend as an in-memory Loader entry | (mounts a backend row) | +| [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` | +| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` | +| [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` | +| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | +| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | +| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | -`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. +`apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7fc730ed9e..07b0e1569e 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -1,16 +1,16 @@ -# host/ — web GUI 宿主半侧 +# host/ — Web GUI 宿主侧 [English](README.md) | 中文 -dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 +dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),由它提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。 -| 包 | 角色 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` | -| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | -| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | -| `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) | -| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) | -| `directory-picker-auto/` | 自适应选择器:启动时一次性判定宿主处境(绑定宿主、SSH、显示),并把匹配的双面后端挂载为内存中的 Loader 条目 | (挂载一个后端行) | +| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议契约 | `ctx.apiProxy` | +| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` | +| [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` | +| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | +| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | +| [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | -`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 +`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e7b813d391..f76e6ec43c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae -README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7 +README.md: aea11a0665f1dd4493da8bd09e0fbb974f93afce +README.zh.md: 36b89a50b09385fa1681a25a19c2e1bea1a2bff9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ed04a3431e..aea11a0665 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml). ## Contract layer (`/api`) @@ -12,21 +12,19 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. -`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. +`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until an Agent-bound ordinary-session operation attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. +Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` reads its source from attached state or persistence inspection without acquiring an Agent, then maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published ordinary child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace, or the nearest workspace-owning ancestor when the source is a subagent. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization. +Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. -Pending inbox input is a live control-plane contract, not session history. The gateway mirrors `InboxItem` occurrences from `agent/inbox/*` with their `queued` or `steering` placement and broadcasts authoritative `session/queue` snapshots on every change and reconnect. A steering occurrence remains in this projection until the corresponding durable `steering/message` has been published, preserving the Host's linear event order during the handoff. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. - -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. @@ -34,13 +32,11 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. - -The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) @@ -56,9 +52,9 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). -- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); an unknown method fails loud at envelope parse rather than getting a not-implemented code. +- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. +- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). -- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `session.history` is inspection-only, but an Agent-bound ordinary-session operation resumes a cold session and writes the pickup boundary. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). +- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e50a7f49cb..36b89a50b0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,45 +2,41 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 ## 契约层(`/api`) -协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。 +协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 +`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 -`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到某项绑定到 Agent 的普通会话操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 +会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent,再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace;若源会话是 subagent,则改为附加到最近拥有 Workspace 的祖先。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。 +待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -待处理的 inbox 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 `InboxItem` 入队项及其 `queued` 或 `steering` placement,并在每次变更和重连时广播权威的 `session/queue` 快照。steering 入队项会一直保留在该投影中,直到对应的持久 `steering/message` 已发布,从而在交接期间保持 Host 的线性事件顺序。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。 - -`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 - -`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) @@ -50,15 +46,15 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。 -#### KV 缓存影响 +#### KV Cache 影响 无;该包既不组装也不发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 -- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 +- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 +- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 -- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`session.history` 只执行检查,但绑定到 Agent 的普通会话操作会恢复冷会话并写入这条拾起边界。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 03f31c6a49..426860eebc 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -34,9 +34,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fd19541590..838e4f3a92 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,14 +8,12 @@ import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId, -} from '@deepseek-ai/dsh-agent' -import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' +import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' @@ -30,7 +28,7 @@ import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, + QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' import { @@ -70,7 +68,7 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import { openNativePath } from './native-path-opener.ts' +import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -85,7 +83,7 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ -const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) /** Product settings intentionally exposed beside model-provider namespaces. */ const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) @@ -135,30 +133,19 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> { /** * Build the provider/model catalog over every registered route. Shared by the - * session-scoped `session.models` (which passes the session's current target - * so an unlisted current model still renders selectable) and the host-scoped - * `llm.models` (no current). Per-provider failures ride `failures` without - * failing the sound groups; groups that advertise nothing are dropped. + * session-scoped `session.models` and host-scoped `llm.models`. Catalog + * membership stays advisory: an unlisted session target remains valid for + * provider dispatch, but is not injected back into the selector after its + * owning catalog stops advertising it. Per-provider failures ride `failures` + * without failing the sound groups; groups that advertise nothing are dropped. */ -async function buildModelCatalog( - ctx: Context, - current?: { provider: string; model: string }, -): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> { +async function buildModelCatalog(ctx: Context): Promise<{ + groups: ModelProviderGroup[] + failures: ModelCatalogFailure[] +}> { const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { try { - const advertised = await ctx.llm.listModels(provider.id) - const models = [...advertised] - if ( - current !== undefined - && provider.id === current.provider - && !models.some(model => model.id === current.model) - ) { - models.push({ - provider: provider.id, - id: current.model, - name: current.model, - }) - } + 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 @@ -179,12 +166,6 @@ async function buildModelCatalog( id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, - ...current !== undefined - && provider.id === current.provider - && model.id === current.model - && !advertised.some(candidate => candidate.id === current.model) - ? { unlisted: true as const } - : {}, ...reasoning === undefined ? {} : { reasoning }, } })) @@ -356,6 +337,8 @@ export interface ApiProxyDefaults { workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise<void> + /** Native text-editor handoff; injectable for settings-document tests. */ + openTextFile?: (path: string, signal: AbortSignal) => Promise<void> } /** The tool/call payload fields the presenter path reads. */ @@ -777,128 +760,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) }) - /** - * Per-session pending-occurrence mirror serving live and mux-open - * `session/queue` snapshots. It carries both queued and steering placements. - * Each terminal inbox event retires one matching occurrence, so repeated - * sends of the same identified message remain visible until every occurrence - * is claimed. - */ - const queuedMirror = new Map<SessionId, InboxItem[]>() - type UnseenQueueEvent = - | { readonly kind: 'update'; readonly item: InboxItem } - | { readonly kind: 'terminal' } - const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>() - const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => { - let events = unseenQueueEvents.get(sessionId) - if (events === undefined) { - events = new Map() - unseenQueueEvents.set(sessionId, events) + /** Project both durable inbox lists, optionally including the splice currently being emitted. */ + const queueItems = ( + agent: Agent, + splice?: SessionEventMap['agent/inbox/spliced'], + ): QueuedInboxItem[] => { + 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 } - events.set(itemId, event) - // Only synchronous re-entrancy may deliver a mutation before its outer - // enqueue observer. Drop unmatched protocol-invalid observations instead - // of retaining process-local ids indefinitely. - queueMicrotask(() => { - const current = unseenQueueEvents.get(sessionId) - if (current?.get(itemId) !== event) return - current.delete(itemId) - if (current.size === 0) unseenQueueEvents.delete(sessionId) - }) - } - const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => { - const events = unseenQueueEvents.get(sessionId) - const event = events?.get(itemId) - if (event === undefined) return undefined - events?.delete(itemId) - if (events?.size === 0) unseenQueueEvents.delete(sessionId) - return event - } - const publishQueue = (sessionId: SessionId): void => { - const items = queuedMirror.get(sessionId) ?? [] - broadcast({ - type: 'session/queue', - sessionId, - items: items.map(item => ({ - id: item.id, - placement: item.placement, - message: item.message, + return [ + ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })), + ...project('next-step').map(message => ({ + id: message.id, + // Only user-origin messages are steering; injected context (approval + // notices, task completion, attached snapshots) is not a user action + // and must not render as a pending steering bubble. + placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + message, })), - }) - } - ctx.effect(() => { - const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => { - const entries = queuedMirror.get(sessionId) - if (entries === undefined) return false - const index = entries.findIndex(entry => entry.id === itemId) - if (index === -1) return false - entries.splice(index, 1) - if (entries.length === 0) queuedMirror.delete(sessionId) - return true - } - const retire = (agent: Agent, item: InboxItem): boolean => { - if (retireKnown(agent.id, item.id)) return true - rememberUnseen(agent.id, item.id, { kind: 'terminal' }) - return false - } - const disposers = [ - ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { - const unseen = takeUnseen(agent.id, item.id) - if (unseen?.kind === 'terminal') return - let entries = queuedMirror.get(agent.id) - if (entries === undefined) { - entries = [] - queuedMirror.set(agent.id, entries) - } - entries.push(unseen?.kind === 'update' ? unseen.item : item) - publishQueue(agent.id) - }), - ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => { - const entries = queuedMirror.get(agent.id) - if (entries === undefined) { - rememberUnseen(agent.id, item.id, { kind: 'update', item }) - return - } - const index = entries.findIndex(entry => entry.id === item.id) - if (index === -1) { - rememberUnseen(agent.id, item.id, { kind: 'update', item }) - return - } - entries.splice(index, 1, item) - publishQueue(agent.id) - }), - ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { - if (item.placement === 'steering') { - // AgentLoop appends the durable steering/message synchronously after - // this claim. Retain and retire the mirror row in the following - // microtask so any re-entrant snapshot and the Host's linear mux - // stream keep it visible until the durable event exists. - const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true - if (!present) { - retire(agent, item) - return - } - queueMicrotask(() => { - if (retireKnown(agent.id, item.id)) publishQueue(agent.id) - }) - } else if (retire(agent, item)) { - // Queued claims have no durable same-message handoff to order. - // Publish retirement synchronously as before. - publishQueue(agent.id) - } - }), - ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { - let changed = false - for (const item of items) changed = retire(agent, item) || changed - if (changed) publishQueue(agent.id) - }), - ctx.on('session/disposed', (session: Session) => { - queuedMirror.delete(session.id) - unseenQueueEvents.delete(session.id) - }), ] - return () => { for (const dispose of disposers) dispose() } - }, 'api-proxy: queued mirror') + } + + ctx.on('session/event', (session, event) => { + if (event.type !== 'agent/inbox/spliced') return + const agent = ctx.agents.get(session.id) + if (agent?.session !== session) return + broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) }) + }) /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */ function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void { @@ -1081,7 +972,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) const inspected = await persistence.inspect(sessionId) if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - return inspected + return { meta: inspected.meta, events: [...inspected.events] } } /** @@ -1402,6 +1293,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } } + /** Open one Host-resolved target and map native failures onto the wire vocabulary. */ + async function openTarget( + request: RpcRequest<unknown>, path: string, signal: AbortSignal, + open: (path: string, signal: AbortSignal) => Promise<void>, + ): Promise<RpcResponse<{ opened: true }>> { + try { + await open(path, signal) + return ok(request, { opened: true as const }) + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'path open was aborted', + details: {}, + }) + } + return err(request, { + code: 'internal', + message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + } + + /** Open one Host-resolved path with its default application. */ + function openPath( + request: RpcRequest<unknown>, path: string, signal: AbortSignal, + ): Promise<RpcResponse<{ opened: true }>> { + const open = defaults.openPath + ?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal)) + return openTarget(request, path, signal, open) + } + + /** Open one Host-resolved text document in a native editor. */ + function openTextFile( + request: RpcRequest<unknown>, path: string, signal: AbortSignal, + ): Promise<RpcResponse<{ opened: true }>> { + const open = defaults.openTextFile + ?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal)) + return openTarget(request, path, signal, open) + } + /** Missing-service report shared by the credentials domain. */ function credentialsAbsent(): RpcError { return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} } @@ -1556,7 +1489,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro page = await sessionQuery.searchSessions({ query: request.payload.query, eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'type', values: ['user/message', 'assistant/message'] }, { kind: 'surface', values: ['current'] }, ], limit: requestedPageLimit, @@ -1724,7 +1657,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current - const { groups, failures } = await buildModelCatalog(ctx, current) + const { groups, failures } = await buildModelCatalog(ctx) return ok(request, { current: { ...current }, groups, failures }) }, @@ -1908,21 +1841,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { itemId }, })) } - const result = agent.updateInbox(itemId, action) - if (result === 'not-found') { + const target = agent.inbox.nextTurn.some(message => message.id === itemId) + ? 'next-turn' + : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined + const message = target === undefined + ? undefined + : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep) + .find(candidate => candidate.id === itemId) + if (target === undefined || message === undefined) { return Promise.resolve(err(request, { code: 'queue-item-not-found', message: 'queued item is no longer pending', details: { itemId }, })) } - if (result === 'steer-unavailable') { + if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { return Promise.resolve(err(request, { code: 'steer-unavailable', message: 'current turn no longer accepts steering', details: { itemId }, })) } + if (action.kind === 'edit') { + agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content })) + } else { + agent.inbox.remove(itemId) + if (action.kind === 'steer') agent.steer(message) + } return Promise.resolve(ok(request, { accepted: true as const })) }, @@ -2280,25 +2225,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async openPath(request, signal) { - try { - const open = defaults.openPath - ?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal)) - await open(request.payload.path, signal) - return ok(request, { opened: true as const }) - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'path open was aborted', - details: {}, - }) - } - return err(request, { - code: 'internal', - message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) - } + return openPath(request, request.payload.path, signal) }, }, @@ -2445,11 +2372,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const exposed = exposedNamespaces() return Promise.resolve(ok(request, { writable: settings.writable, + hasDocument: settings.documentPath !== undefined, namespaces: settings.describe({ redactSecrets: true }) .filter(descriptor => exposed.has(String(descriptor.ns))) .map(namespaceView), })) }, + async openDocument(request, signal) { + const settings = ctx.get('settings') + if (settings === undefined) return err(request, settingsAbsent()) + if (isAborted(signal)) { + return err(request, { + code: 'cancelled', + message: 'settings document open was aborted', + details: {}, + }) + } + let path: string | undefined + try { + path = await settings.prepareDocument() + } catch (error: unknown) { + if (isAborted(signal)) { + return err(request, { + code: 'cancelled', + message: 'settings document preparation was aborted', + details: {}, + }) + } + return err(request, { + code: 'internal', + message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + if (path === undefined) { + return err(request, { + code: 'internal', + message: 'settings provider has no local document to open', + details: {}, + }) + } + if (isAborted(signal)) { + return err(request, { + code: 'cancelled', + message: 'settings document open was aborted', + details: {}, + }) + } + return openTextFile(request, path, signal) + }, update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision), replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision), mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision), @@ -2559,16 +2530,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Queue snapshot baseline (pendingQuestions precedent): frames replayed // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. - for (const [sessionId, items] of queuedMirror) { - queue.push(frame({ - type: 'session/queue', - sessionId, - items: items.map(item => ({ - id: item.id, - placement: item.placement, - message: item.message, - })), - })) + for (const session of ctx.sessions.list()) { + const agent = ctx.agents.get(session.id) + if (agent?.session === session && agent.inbox.hasPending) { + queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) + } } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 3d5e94c2c1..13ead7d08d 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' import { - contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, + contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, } from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' @@ -53,8 +53,8 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ type: z.literal('session/queue'), sessionId: sessionIdSchema, items: z.array(z.object({ - id: inboxItemIdSchema, - placement: z.union([z.literal('queued'), z.literal('steering')]), + id: messageIdSchema, + placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]), message: messageSchema, })), }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index f4460fadc2..7fc54f6f01 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -1,5 +1,5 @@ /** - * events domain contract: signatures and frame unions for the two SSE + * events domain contract: signatures and frame unions for the two logical * streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request * view) — rpcId must be exposed to the business layer, because responses to answerable frames * (approval/question requested) echo it; for pure pushes it identifies that one push. @@ -9,7 +9,7 @@ import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' import type { Message } from '@deepseek-ai/dsh-llm/types' -import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' @@ -34,15 +34,15 @@ export type ToolEventView = /** One pending inbox occurrence in the authoritative `session/queue` snapshot. */ export interface QueuedInboxItem { - /** Agent-owned occurrence identity; queue mutations address only `queued` items. */ - id: InboxItemId - /** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */ - placement: 'queued' | 'steering' + /** Message identity used by inbox mutations. */ + id: MessageId + /** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */ + placement: 'queued' | 'steering' | 'context' /** Complete pending message; it is not durable until the Agent claims it. */ message: Message } -/** Streaming face of the contract: the two SSE stream openers (mux + host). */ +/** Streaming face of the contract: the two logical stream openers (mux + host). */ export interface EventsApi { /** * All-session aggregated mux stream. On open, emits a subscribed control frame for every diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 7f2f55cba4..2292ae2ffb 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -1,7 +1,7 @@ /** * apiproxy contract-layer barrel. api/ has zero Node dependencies and is - * importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are - * merely physical channels (four-quadrant message model). + * importable from the browser; the TS interfaces are the authoritative contract, while HTTP, + * WebSocket, and in-process SSE are merely physical channels (four-quadrant message model). */ import type { SessionsApi } from './sessions.ts' @@ -71,7 +71,6 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' -export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' // ---- Fixed session-search product bounds ---- export { diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 59a21cf12a..a62319fd62 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -3,8 +3,8 @@ * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the * live route registry; `llm.models` is the session-independent model catalog - * (`session.models` minus the per-session current/unlisted logic). Both - * invalidate on the `host/models-changed` frame. + * (the same groups as `session.models`, without the per-session current + * target). Both invalidate on the `host/models-changed` frame. */ import type { RpcRequest, RpcResponse } from './rpc.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index efcbb87d19..0378f885e1 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -57,6 +57,7 @@ export interface RpcMethodMap { 'goal.complete': GoalsApi['complete'] 'goal.clear': GoalsApi['clear'] 'settings.describe': SettingsApi['describe'] + 'settings.openDocument': SettingsApi['openDocument'] 'settings.update': SettingsApi['update'] 'settings.replace': SettingsApi['replace'] 'settings.mutate': SettingsApi['mutate'] diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index f48a2ca562..3b92edc661 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -1,15 +1,15 @@ /** - * Four-quadrant RPC message model. Channels and messages are - * decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical - * messages are channel-independent, and the wire full form is a four-member discriminated union. + * Four-quadrant RPC message model. Channels and messages are decoupled: HTTP, + * WebSocket, and in-process SSE are physical carriers, while logical messages + * are channel-independent and form a four-member discriminated union. * api/ contract layer: zero Node dependencies, importable from the browser. */ import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' /** * Message correlation id: the initiator mints it on a request; a response @@ -45,8 +45,8 @@ export interface RpcErrorDetailsMap { 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } - 'queue-item-not-found': { itemId: InboxItemId } - 'steer-unavailable': { itemId: InboxItemId } + 'queue-item-not-found': { itemId: MessageId } + 'steer-unavailable': { itemId: MessageId } /** A known slash command reported a usage/state error; the message is the command's own text. */ 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ @@ -147,7 +147,7 @@ export interface ServerResponse { } /** - * Message initiated by the server (wire carrier: SSE frame). Answerable interactions + * Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions * (approval/question requested — stable rpcId, reused on replay) and pure pushes * (session/event etc. — rpcId identifies that one push) share this shape; whether a * response is expected is determined statically by method (a strict dichotomy, no third kind). diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 1675e396d6..9f9c4329e6 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -25,8 +25,8 @@ import { /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId> -/** InboxItemId: one brand cast after non-empty string validation. */ -export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType<InboxItemId> +/** MessageId: one brand cast after non-empty string validation. */ +export const messageIdSchema = z.string().min(1) as unknown as z.ZodType<MessageId> /** * WorkspaceId: the workspace domain's one brand cast. Hosted here rather @@ -165,7 +165,6 @@ export const modelCatalogModelSchema = z.object({ id: z.string().min(1), name: z.string().min(1), description: z.string().optional(), - unlisted: z.literal(true).optional(), reasoning: modelReasoningSchema.optional(), }) satisfies z.ZodType<Wire<ModelCatalogModel>> @@ -265,7 +264,7 @@ export const sessionPromptValueSchema = z.object({ /** session.updateQueue request payload. */ export const sessionUpdateQueueRequestSchema = z.object({ sessionId: sessionIdSchema, - itemId: inboxItemIdSchema, + itemId: messageIdSchema, action: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), z.object({ kind: z.literal('remove') }), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f55cb87001..18315eef19 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -4,8 +4,8 @@ * else references RequestPayload<'session.*'> / ResponseValue<'session.*'>. */ +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' // The pure-type outlet: api/ is browser-importable, and the package root's // cordis Context merge (via dsh-agent) must not enter client aggregates. @@ -89,8 +89,6 @@ export interface ModelCatalogModel { name: string /** Optional provider-supplied description. */ description?: string - /** The current model was inserted because the advisory catalog omitted it. */ - unlisted?: true /** Exact-route reasoning metadata when the adapter exposes it. */ reasoning?: ModelReasoning } @@ -289,7 +287,7 @@ export interface SessionsApi { * Edits, removes, or strictly steers one pending queued occurrence on an ordinary session. * Session-backed subagents reject with `agent-busy`. */ - updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): + updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>): Promise<RpcResponse<{ accepted: true }>> /** diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts index 56ac16f93d..19fa2c012e 100644 --- a/packages/host/apiproxy/src/api/settings.schema.ts +++ b/packages/host/apiproxy/src/api/settings.schema.ts @@ -32,9 +32,18 @@ export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wi /** settings.describe response value. */ export const settingsDescribeValueSchema = z.object({ writable: z.boolean(), + hasDocument: z.boolean(), namespaces: z.array(settingsNamespaceViewSchema), }) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>> +/** settings.openDocument request payload. */ +export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>> + +/** settings.openDocument response value. */ +export const settingsOpenDocumentValueSchema = z.object({ + opened: z.literal(true), +}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>> + /** settings.update request payload. */ export const settingsUpdateRequestSchema = z.object({ ns: z.string().min(1), diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index 30327f19d1..5bbc6d56d9 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -53,10 +53,26 @@ export type SettingsPathOpView = export interface SettingsApi { /** * Describe every registered namespace: redacted layered values plus the - * serialized schema a client renders its form from. `writable: false` - * (read-only provider) tells the client to disable every write control. + * serialized schema a client renders its form from. `hasDocument` reports + * whether a file-backed provider owns a local document without exposing its + * Host path. This method is loopback-only; `writable: false` (read-only + * provider) tells the client to disable every write control. */ - describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>> + describe(request: RpcRequest<{}>): Promise<RpcResponse<{ + writable: boolean + hasDocument: boolean + namespaces: SettingsNamespaceView[] + }>> + + /** + * Materialize the configured local document when absent and ask the Host to + * hand it to the platform text-document opener. macOS forces a text editor; + * Linux and Windows use the desktop file association. The request carries + * no path, so the browser cannot choose an arbitrary Host filesystem target. + */ + openDocument( + request: RpcRequest<{}>, signal: AbortSignal, + ): Promise<RpcResponse<{ opened: true }>> /** * Merge a patch into one namespace's user layer (validate → persist → diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 758fe638ca..0aa630b328 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -1,6 +1,6 @@ /** * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting, - * four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct + * four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct * IApiClient domain methods (business code never mints). Platform differences ride two aspects: * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched. */ @@ -49,7 +49,8 @@ import { goalClearValueSchema, } from '../api/goals.schema.ts' import { - settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema, + settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema, + settingsReplaceValueSchema, settingsUpdateValueSchema, } from '../api/settings.schema.ts' import { credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, @@ -69,8 +70,8 @@ import { * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls * carry only that external signal. In both cases the signal rides beside the request, never * on the wire, like the stream signatures. - * Stream methods accept an optional onOpen callback: it fires once the SSE transport is - * readable (response headers received, before any frame) — the "stream established" signal + * Stream methods accept an optional onOpen callback: it fires once the physical transport is + * readable (before any frame) — the "stream established" signal * connection controllers need for the readiness handshake. Generators are lazy, so the * underlying fetch (and therefore onOpen) only happens once iteration starts. * Relationship: ApiProxy is the narrow-form signature contract the impl side implements; @@ -132,6 +133,7 @@ export interface IApiClient { } settings: { describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>> + openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>> update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>> replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>> mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>> @@ -189,6 +191,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV 'goal.complete': goalCompleteValueSchema, 'goal.clear': goalClearValueSchema, 'settings.describe': settingsDescribeValueSchema, + 'settings.openDocument': settingsOpenDocumentValueSchema, 'settings.update': settingsUpdateValueSchema, 'settings.replace': settingsReplaceValueSchema, 'settings.mutate': settingsMutateValueSchema, @@ -449,6 +452,7 @@ export abstract class AbstractApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: (payload, signal) => this.callUnary('settings.describe', payload, signal), + openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal), update: (payload, signal) => this.callUnary('settings.update', payload, signal), replace: (payload, signal) => this.callUnary('settings.replace', payload, signal), mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index cf1ff16728..8feffc63b6 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -51,7 +51,8 @@ import { goalClearRequestSchema, } from '../api/goals.schema.ts' import { - settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema, + settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema, + settingsReplaceRequestSchema, settingsUpdateRequestSchema, } from '../api/settings.schema.ts' import { credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, @@ -69,9 +70,8 @@ import { * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise. * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation * documented on Wire); the dispatch point carries the one Wire→exact cast. - * Every invoke receives the carrier Request's signal; methods whose contract - * declares a signal parameter (session.search and command.execute) forward it, - * the rest ignore it. + * Every invoke receives the carrier Request's signal; routes whose contract + * declares a signal parameter forward it, and the rest ignore it. */ type UnaryRoutes = { [K in keyof RpcMethodMap]: { @@ -116,6 +116,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) }, 'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) }, 'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) }, + 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) }, 'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) }, 'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) }, 'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 21330079c2..e279575ff4 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -5,7 +5,7 @@ * platform subclasses on the client side), and the host-side implementation * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing * `ctx.apiProxy`). Transport-agnostic by design: this package registers no - * routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. + * routes — physical carriers wrap `ctx.apiProxy` themselves. */ import { resolve } from 'node:path' diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a4fbbaa72e..a9fdd56bc4 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,4 +1,4 @@ -/** Cross-platform open-with-default-application used by the local GUI carrier. */ +/** Cross-platform native path and text-document openers used by the local GUI carrier. */ import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' @@ -11,27 +11,26 @@ export interface PathOpenerInternals { run?: PathOpenerRunner } +/** Native path-open intent; macOS distinguishes text editing from file association. */ +type PathOpenIntent = 'default' | 'text-editor' + /** PowerShell single-quoted literal (doubles embedded quotes). */ function powershellLiteral(path: string): string { return `'${path.replace(/'/g, "''")}'` } -/** - * Open a filesystem path with the operating system's default application. - * @param path - absolute or host-resolvable path (caller owns resolution). - * @param signal - caller/connection lifetime; abort terminates the native command. - * @param internals - platform and runner seam for deterministic tests. - */ -export async function openNativePath( +/** Dispatch one shell-free platform command for the requested open intent. */ +async function openNativePathWithIntent( path: string, signal: AbortSignal, + intent: PathOpenIntent, internals: PathOpenerInternals = {}, ): Promise<void> { const platform = internals.platform ?? process.platform const run = internals.run ?? runNativeCommand if (platform === 'darwin') { - await run('open', [path], signal) + await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal) return } @@ -51,3 +50,32 @@ export async function openNativePath( throw new Error(`native path opener is unsupported on ${platform}`) } + +/** + * Open a filesystem path with the operating system's default application. + * @param path - absolute or host-resolvable path (caller owns resolution). + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - platform and runner seam for deterministic tests. + */ +export function openNativePath( + path: string, + signal: AbortSignal, + internals: PathOpenerInternals = {}, +): Promise<void> { + return openNativePathWithIntent(path, signal, 'default', internals) +} + +/** + * Open a text document for editing; macOS bypasses the file-type association + * so a YAML association with a browser cannot consume the gesture. + * @param path - absolute or host-resolvable text-document path. + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - platform and runner seam for deterministic tests. + */ +export function openNativeTextFile( + path: string, + signal: AbortSignal, + internals: PathOpenerInternals = {}, +): Promise<void> { + return openNativePathWithIntent(path, signal, 'text-editor', internals) +} diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index f9a00cf9ef..4833667583 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -34,7 +34,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { /** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */ function agentOf(ctx: Context): Agent { const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) return { session } as unknown as Agent } @@ -185,7 +185,7 @@ describe('approval pending registry', () => { const abort = new AbortController() const mux = openMux(api, abort) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' }) const agent = { session } as unknown as Agent const cancelled = new AbortController() @@ -308,7 +308,7 @@ describe('approval pending registry', () => { // Bypass ApprovalService: a log whose sole asked event already has its // decided partner must not be re-claimed — the answerer delegates. const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' }) session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' }) const agent = { session } as unknown as Agent @@ -322,7 +322,7 @@ describe('approval pending registry', () => { // Bypass ApprovalService: dispatch the waterfall directly with a session // that has no approval/asked event — the proxy answerer must call next(). const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const agent = { session } as unknown as Agent const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const)) expect(outcome).toBe('unavailable') diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 36d51a7542..4f8637068e 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -79,7 +79,7 @@ describe('summary blank = conversation not started', () => { const session = ctx.sessions.create() attach(session) appendStandalone(session) - session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 0 }) expect(await listBlank(api, session.id)).toBe(false) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 7eeeba3af6..8b01e9005a 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -10,10 +10,17 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { + PersistenceCoordinator, + SessionPersistenceRevision, + type PersistenceBackend, + type StoredPrefix, +} from '@deepseek-ai/dsh-session-persistence' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -89,7 +96,7 @@ describe('attached updatedAt excludes end-seed', () => { const worked = 1_000_000 const resumed = ctx.sessions.create(sid('resumed-untouched'), { seed: [ - { type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } }, ], meta: { cwd: '/proj', createdAt: 500 }, @@ -105,7 +112,7 @@ describe('attached updatedAt excludes end-seed', () => { expect(summary?.updatedAt).toBe(worked) // Real work appended after end-seed does move it. - resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + resumed.append('turn/start', { turn: 2 }) const after = await api.sessions.list(request({})) if (!after.result.ok) throw new Error('list failed') const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched') @@ -113,6 +120,66 @@ describe('attached updatedAt excludes end-seed', () => { }) }) +describe('cold history recovery view', () => { + it('shows in-memory interruption repair without activating the session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-interrupted') + const meta = header(sessionId, 1000) + const stored: StoredPrefix<never> = { + meta, + events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + revision: SessionPersistenceRevision('history-recovery-test:1'), + } + const backend: PersistenceBackend<never> = { + 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) + ctx.provide('sessionPersistence', { + list: (signal?: AbortSignal) => backend.list(signal), + inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), + locate: () => undefined, + } as never) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) + if (!history.result.ok) throw new Error('history failed') + expect(history.result.value.events.map(entry => entry.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('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() @@ -216,7 +283,7 @@ describe('subagent ownership fence', () => { const queued = await api.sessions.updateQueue(request({ sessionId: originChild.id, - itemId: InboxItemId('queued-item'), + itemId: MessageId('queued-item'), action: { kind: 'remove' }, })) expect(queued.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 4cf975dc99..1ab33897e3 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' -import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -20,7 +20,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import CommandService from '@deepseek-ai/dsh-commands' import SkillService from '@deepseek-ai/dsh-skill' -import type { HostFrame, MuxFrame } from '../src/api/index.ts' +import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' @@ -63,7 +63,14 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}): /** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ function stubAgent(ctx: Context, sessionId?: SessionId): Agent { const session = ctx.sessions.create(sessionId) - const agent = { id: session.id, session, status: 'idle', ctx } as Agent + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const agent = { + id: session.id, + session, + inbox, + status: 'idle', + ctx, + } as Agent ctx.agents.register(agent) return agent } @@ -78,6 +85,13 @@ async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, return frames } +/** Read the next payload from an open stream. */ +async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> { + const result = await iterator.next() + if (result.done) throw new Error('stream ended') + return result.value.payload +} + describe('command.list', () => { it('serves the addressed agent\'s name-sorted catalog', async () => { const ctx = await harness() @@ -99,17 +113,6 @@ describe('command.list', () => { expect(error.code).toBe('internal') expect(error.message).toContain('command registry') }) - - it('does not route a live subagent through the generic command domain', async () => { - const ctx = await harness() - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj', origin: 'subagent' } }) - const agent = { id: session.id, session, status: 'idle', ctx } as Agent - ctx.agents.register(agent) - const api = createApiProxy(ctx, DEFAULTS) - - const error = expectErr(await api.commands.list(request({ sessionId: agent.id }))) - expect(error).toMatchObject({ code: 'agent-busy' }) - }) }) describe('command.execute', () => { @@ -153,7 +156,7 @@ describe('command.execute', () => { const api = createApiProxy(ctx, DEFAULTS) const missing = expectErr(await api.commands.execute( request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // Cold Agent-bound access fails loud when persistence is absent. + expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate const bare = await harness({ commands: false }) const bareApi = createApiProxy(bare, DEFAULTS) @@ -275,7 +278,7 @@ describe('host/commands-changed frame', () => { }) }) -/** Build one frozen inbox message for the live `agent/inbox/*` events. */ +/** Build one frozen inbox message. */ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { return freezeMessage({ id: MessageId(id), @@ -285,28 +288,19 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { }) } -/** Build one addressable inbox occurrence around a frozen message. */ -function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem { - return { id: InboxItemId(id), message, placement } -} - describe('session.updateQueue', () => { - it('routes addressable actions and reports strict steer races', async () => { + it('splices a queued message and reports a lost claim race', async () => { const ctx = await harness() const agent = stubAgent(ctx) - const seen: unknown[] = [] - agent.updateInbox = (id, action) => { - seen.push({ id, action }) - if (id === InboxItemId('present')) return 'applied' - return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found' - } + const present = inboxMessage('present', 'before') + agent.inbox.splice('next-turn', 0, 0, [present]) const api = createApiProxy(ctx, DEFAULTS) const applied = await api.sessions.updateQueue({ rpcId: RpcId('q-apply'), payload: { sessionId: agent.id, - itemId: InboxItemId('present'), + itemId: MessageId('present'), action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, }, }) @@ -315,28 +309,15 @@ describe('session.updateQueue', () => { rpcId: RpcId('q-missing'), payload: { sessionId: agent.id, - itemId: InboxItemId('claimed'), + itemId: MessageId('claimed'), action: { kind: 'remove' }, }, }) expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) - const closed = await api.sessions.updateQueue({ - rpcId: RpcId('q-closed'), - payload: { - sessionId: agent.id, - itemId: InboxItemId('closed'), - action: { kind: 'steer' }, - }, + expect(agent.inbox.nextTurn[0]).toMatchObject({ + id: 'present', + content: [{ type: 'text', text: 'edited' }], }) - expect(expectErr(closed)).toMatchObject({ - code: 'steer-unavailable', - details: { itemId: 'closed' }, - }) - expect(seen).toEqual([ - { id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } }, - { id: 'claimed', action: { kind: 'remove' } }, - { id: 'closed', action: { kind: 'steer' } }, - ]) }) it('rejects a stale occurrence without resuming a cold agent', async () => { @@ -347,7 +328,7 @@ describe('session.updateQueue', () => { rpcId: RpcId('q-cold'), payload: { sessionId: 'cold-session' as SessionId, - itemId: InboxItemId('stale-item'), + itemId: MessageId('stale-item'), action: { kind: 'remove' }, }, }) @@ -358,222 +339,64 @@ describe('session.updateQueue', () => { }) describe('session/queue frames', () => { - it('folds nested mutations observed before their outer enqueue', async () => { + it('publishes authoritative inbox snapshots without duplicating message identity', async () => { const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) - const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued') - const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued') - const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued') - ctx.on('agent/inbox/enqueue', (subject, item) => { - if (subject !== agent) return - if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited) - if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed]) + const queued = inboxMessage('m-1', 'queued prompt') + const edited = inboxMessage('m-1', 'edited prompt') + const steering = inboxMessage('m-2', 'steering prompt') + agent.inbox.splice('next-turn', 0, 0, [queued]) + agent.inbox.splice('next-step', 0, 0, [steering]) + + const abort = new AbortController() + const iterator = api.events.mux({ + rpcId: RpcId('t-mux-baseline'), + payload: {}, + }, abort.signal)[Symbol.asyncIterator]() + const frames = [ + await nextFrame(iterator), + await nextFrame(iterator), + ] + agent.inbox.splice('next-turn', 0, 1, [edited]) + frames.push(await nextFrame(iterator), await nextFrame(iterator)) + const injected = freezeMessage({ + id: MessageId('m-3'), + role: 'user', + content: [{ type: 'text' as const, text: 'injected context' }], + source: { kind: 'plugin' as const, plugin: 'approval' }, }) - const api = createApiProxy(ctx, DEFAULTS) - const live = new AbortController() - const collected = collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live) + agent.inbox.splice('next-step', 0, 0, [injected]) + frames.push(await nextFrame(iterator), await nextFrame(iterator)) + abort.abort() + await iterator.return?.() - ctx.emit('agent/inbox/enqueue', agent, original) - ctx.emit('agent/inbox/enqueue', agent, removed) - - const liveFrames = (await collected).filter(frame => frame.type === 'session/queue') - expect(liveFrames.map(frame => frame.items)).toEqual([ - [{ id: edited.id, placement: edited.placement, message: edited.message }], - ]) - const replay = new AbortController() - const replayFrames = await collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay) - expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames) - }) - - it('expires unmatched mutations after the synchronous re-entry window', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const original = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'original'), 'queued') - const staleEdit = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'stale edit'), 'queued') - const staleTerminal = inboxItem('i-stale-terminal', inboxMessage('m-stale-terminal', 'keep me'), 'queued') - - ctx.emit('agent/inbox/update', agent, staleEdit) - ctx.emit('agent/inbox/discard', agent, [staleTerminal]) - await Promise.resolve() - ctx.emit('agent/inbox/enqueue', agent, original) - ctx.emit('agent/inbox/enqueue', agent, staleTerminal) - - const replay = new AbortController() - const frames = await collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-expired-unseen'), payload: {} }, replay.signal), 2, replay) - expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([{ - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: original.id, placement: original.placement, message: original.message }, - { id: staleTerminal.id, placement: staleTerminal.placement, message: staleTerminal.message }, - ], - }]) - }) - - it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const live = new AbortController() - const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + one snapshot per accepted inbox occurrence. - const liveCollected = collect<MuxFrame>(liveStream, 3, live) - - const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') - const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') - ctx.emit('agent/inbox/enqueue', agent, queued) - ctx.emit('agent/inbox/enqueue', agent, steering) - - const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue') - expect(liveFrames).toEqual([ + expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([ { type: 'session/queue', sessionId: agent.id, - items: [{ id: queued.id, placement: 'queued', message: queued.message }], + items: [ + { id: queued.id, placement: 'queued', message: queued }, + { id: steering.id, placement: 'steering', message: steering }, + ], }, { type: 'session/queue', sessionId: agent.id, items: [ - { id: queued.id, placement: 'queued', message: queued.message }, - { id: steering.id, placement: 'steering', message: steering.message }, + { id: edited.id, placement: 'queued', message: edited }, + { id: steering.id, placement: 'steering', message: steering }, + ], + }, + { + type: 'session/queue', + sessionId: agent.id, + items: [ + { id: edited.id, placement: 'queued', message: edited }, + { id: injected.id, placement: 'context', message: injected }, + { id: steering.id, placement: 'steering', message: steering }, ], }, ]) - - // A fresh mux connection replays only the current authoritative snapshot. - const replay = new AbortController() - const replayFrames = await collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) - expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) - }) - - it('publishes the durable steering event before retiring its transient row', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const abort = new AbortController() - const collected = collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort) - const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') - - agent.session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - ctx.emit('agent/inbox/enqueue', agent, steering) - ctx.emit('agent/inbox/dequeue', agent, steering) - agent.session.append('steering/message', { - turn: 1, - message: steering.message, - }, { surfaceOp: 'append' }) - - const frames = await collected - expect(frames.map(frame => frame.type)).toEqual([ - 'session/subscribed', - 'session/event', - 'session/queue', - 'session/event', - 'session/queue', - ]) - expect(frames[2]).toMatchObject({ - type: 'session/queue', - items: [{ id: steering.id, placement: 'steering' }], - }) - expect(frames[3]).toMatchObject({ - type: 'session/event', - event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, - }) - expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] }) - }) - - it('retains claimed steering in re-entrant snapshots until its durable event', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') - const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued') - ctx.on('agent/inbox/dequeue', (subject, item) => { - if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued) - }) - const abort = new AbortController() - const collected = collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort) - - agent.session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - ctx.emit('agent/inbox/enqueue', agent, steering) - ctx.emit('agent/inbox/dequeue', agent, steering) - agent.session.append('steering/message', { - turn: 1, - message: steering.message, - }, { surfaceOp: 'append' }) - - const frames = await collected - expect(frames[3]).toMatchObject({ - type: 'session/queue', - items: [ - { id: steering.id, placement: 'steering' }, - { id: queued.id, placement: 'queued' }, - ], - }) - expect(frames[4]).toMatchObject({ - type: 'session/event', - event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, - }) - expect(frames[5]).toMatchObject({ - type: 'session/queue', - items: [{ id: queued.id, placement: 'queued' }], - }) - }) - - it('publishes edits in place in the authoritative order', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const abort = new AbortController() - const collected = collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort) - const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued') - const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued') - const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued') - ctx.emit('agent/inbox/enqueue', agent, first) - ctx.emit('agent/inbox/enqueue', agent, second) - ctx.emit('agent/inbox/update', agent, edited) - ctx.emit('agent/inbox/dequeue', agent, edited) - - const frames = (await collected).filter(frame => frame.type === 'session/queue') - expect(frames.map(frame => frame.items)).toEqual([ - [{ id: first.id, placement: first.placement, message: first.message }], - [ - { id: first.id, placement: first.placement, message: first.message }, - { id: second.id, placement: second.placement, message: second.message }, - ], - [ - { id: first.id, placement: first.placement, message: first.message }, - { id: edited.id, placement: edited.placement, message: edited.message }, - ], - [{ id: first.id, placement: first.placement, message: first.message }], - ]) - }) - - it('publishes an empty snapshot after terminal discard', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued') - ctx.emit('agent/inbox/enqueue', agent, doomed) - ctx.emit('agent/inbox/discard', agent, [doomed]) - - const abort = new AbortController() - const frames = await collect<MuxFrame>( - api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort) - expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index c50ad95806..ca79ae367d 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -5,7 +5,7 @@ * invalidation frames (settings/credentials/models changed). */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -47,18 +47,35 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string class MemorySettings extends Settings { doc: Record<string, unknown> - constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) { + constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { + doc?: Record<string, unknown> + readOnly?: boolean + documentPath?: string + preparedPath?: string + }) { super(ctx) this.doc = structuredClone(options?.doc ?? {}) this.readOnly = options?.readOnly ?? false + this.path = options?.documentPath + this.preparedPath = options?.preparedPath } private readonly readOnly: boolean + private readonly path: string | undefined + private readonly preparedPath: string | undefined get writable(): boolean { return !this.readOnly } + override get documentPath(): string | undefined { + return this.path + } + + override prepareDocument(): Promise<string | undefined> { + return Promise.resolve(this.preparedPath ?? this.documentPath) + } + protected load(): Promise<Record<string, unknown>> { return Promise.resolve(structuredClone(this.doc)) } @@ -146,7 +163,12 @@ const AdapterConfig = z.object({ }) async function harness(options?: { - settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean } + settings?: false | { + doc?: Record<string, unknown> + readOnly?: boolean + documentPath?: string + preparedPath?: string + } credentials?: false | { shadowed?: string[] } /** Skip the directory registration to exercise a namespace the proxy does not expose. */ configurableProviders?: false @@ -205,11 +227,15 @@ describe('settings domain', () => { }) it('describes layered redacted namespaces with their secret slots', async () => { - const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } }) + const ctx = await harness({ settings: { + doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } }, + documentPath: '/tmp/custom-settings.yaml', + } }) ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) expect(value.writable).toBe(true) + expect(value.hasDocument).toBe(true) expect(value.namespaces).toHaveLength(1) const view = value.namespaces[0]! expect(view.ns).toBe('llm-deepseek') @@ -222,6 +248,62 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) + it('opens the provider-resolved document without accepting a browser path', async () => { + const ctx = await harness({ settings: { + documentPath: '/tmp/described-settings.yaml', + preparedPath: '/tmp/custom-settings.yaml', + } }) + const opened: string[] = [] + const api = createApiProxy(ctx, { + ...DEFAULTS, + openTextFile: (path) => { + opened.push(path) + return Promise.resolve() + }, + }) + + expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal))) + .toEqual({ opened: true }) + expect(opened).toEqual(['/tmp/custom-settings.yaml']) + }) + + it('refuses to open settings when the provider has no local document', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false) + const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) + expect(error.code).toBe('internal') + expect(error.message).toContain('no local document') + }) + + it('does not prepare or open a settings document after cancellation', async () => { + const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } }) + const opened: string[] = [] + const api = createApiProxy(ctx, { + ...DEFAULTS, + openTextFile: (path) => { + opened.push(path) + return Promise.resolve() + }, + }) + const prepare = vi.spyOn(ctx.settings, 'prepareDocument') + const cancelled = new AbortController() + cancelled.abort() + expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code) + .toBe('cancelled') + expect(prepare).not.toHaveBeenCalled() + + const pending = Promise.withResolvers<string | undefined>() + prepare.mockReturnValueOnce(pending.promise) + const duringPrepare = new AbortController() + const opening = api.settings.openDocument(request({}), duringPrepare.signal) + await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() }) + duringPrepare.abort() + pending.resolve('/tmp/settings.yaml') + expect(expectErr(await opening).code).toBe('cancelled') + expect(opened).toEqual([]) + }) + it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index e653bafb4e..9f6ef65e2f 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -59,7 +59,7 @@ function liveAgent( ): Session { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } }) for (let turn = 1; turn <= turns; turn++) { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `prompt ${String(turn)}` }], source: { kind: 'user' }, @@ -67,12 +67,15 @@ function liveAgent( session.append('turn/end', { turn, reason: { kind: 'completed' } }) } if (tail !== 'none') { - session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + 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' } }) + 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 diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 5197d96436..2a4754f144 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -1,7 +1,8 @@ /** * Web session model-directory and selection behavior: dynamic provider grouping, - * provider-local catalog failures, logged-target restoration, advisory unlisted - * models, and the prompt-assembly boundary for a running selection change. + * provider-local catalog failures, logged-target restoration without stale + * catalog injection, advisory pass-through models, and the prompt-assembly + * boundary for a running selection change. */ import { describe, expect, it } from 'vitest' @@ -118,7 +119,7 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { + it('groups successful providers and leaves an unlisted current target out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'private-preview', @@ -143,12 +144,6 @@ describe('Web session model selection', () => { description: 'Reasoning model', reasoning: REASONING, }, - { - id: 'private-preview', - name: 'private-preview', - unlisted: true, - reasoning: REASONING, - }, ], }]) expect(catalog.failures).toEqual([ diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index 14526afefb..a1775a8025 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -1,16 +1,20 @@ /** - * Projection carrier paths of the host ApiProxy: history tail pages snapshot - * attached state or fold one cold inspected prefix, loadOlder omits the block, - * and live unit changes push session/projection frames. + * Projection carrier paths of the host ApiProxy: 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 to mux consumers as a session/projection frame minted here. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' -import AgentRegistry from '@deepseek-ai/dsh-agent' +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 type { Session, SessionEvent, SessionHeader } 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 UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -49,6 +53,8 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(AgentRegistry) if (withRegistry) await ctx.plugin(SessionProjectionRegistry) const session = ctx.sessions.create() + // 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 } } @@ -80,40 +86,6 @@ describe('session.history projections block', () => { expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) }) - it('folds a cold inspected prefix without publishing an Agent', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(lastUserUnit()) - const sessionId = SessionId('session-cold-history') - const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/tmp' } - const events = [{ - type: 'user/message', - seq: 0, - time: 2, - data: createUserMessage({ - content: [{ type: 'text', text: 'persisted' }], - source: { kind: 'user' }, - }), - surfaceOp: 'append', - }] as SessionEvent[] - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - } as never) - - const response = await api(ctx).sessions.history(request({ sessionId })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.projections).toEqual({ - asOfSeq: 0, - values: { 'test/last-user': { text: 'persisted' } }, - }) - expect(ctx.agents.get(sessionId)).toBeUndefined() - }) - it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) @@ -252,7 +224,7 @@ describe('session/projection push frame', () => { seedMessages(session, 1) // Same-reference apply: turn/start does not concern the unit — no frame. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) seedMessages(session, 1) const frames = await collected diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 0e3f0ffe18..15c7361024 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -57,7 +57,7 @@ async function composed(withTitles = true): Promise<Context> { 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, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `prompt ${String(turn)}` }], source: { kind: 'user' }, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index a1460ab9e5..57bb05df4f 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -138,7 +138,7 @@ describe('session.search', () => { eventFilters: [ { kind: 'type', - values: ['user/message', 'assistant/message', 'steering/message'], + values: ['user/message', 'assistant/message'], }, { kind: 'surface', values: ['current'] }, ], @@ -182,7 +182,7 @@ describe('session.search', () => { withBestMatch(0, { sessionId: sid('hidden') }), withBestMatch(1, { surface: 'shadowed' }), withBestMatch(2, { type: 'tool/result' }), - withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }), + withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }), ], }), } as never) diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 717081dc7d..43083545db 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -112,7 +112,7 @@ describe('mux live view computation', () => { const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}` const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' }) @@ -175,7 +175,7 @@ describe('mux live view computation', () => { // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' }) // meta rides through to presentResult's ToolResult (the spread arm). session.append('tool/result', { @@ -241,7 +241,7 @@ describe('mux live view computation', () => { const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') appendAssistantText(session, 'first reply', 1) const third = appendUserText(session, 'second prompt') @@ -295,7 +295,7 @@ describe('mux live view computation', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create('session-doomed' as SessionId) }, { inject: ['sessions'] })) - session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session?.append('turn/start', { turn: 1 }) session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' }) // Disposing the owning fiber detaches the session mid-stream; the // session/disposed listener must clear its open-call table entry. @@ -314,7 +314,7 @@ describe('mux live view computation', () => { const collected = collect(stream, 4, abort) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // The turn/end above cleared the live table; pairing must fall back to diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f006df2906..af315ffcd0 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -44,16 +44,15 @@ function stubAgent(session: Session): Agent { id: session.id, options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: new Context(), + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7d73759f0c..ded1e433b1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -96,7 +96,8 @@ function scriptedApi(overrides: { ...overrides.goals, }, settings: { - describe: r => ok(r, { writable: true, namespaces: [] }), + describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }), + openDocument: r => ok(r, { opened: true as const }), update: err, replace: err, mutate: err, @@ -679,7 +680,8 @@ describe('config unary surface', () => { const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } const api = scriptedApi({ settings: { - describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })), + describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })), + openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })), update: record('settings.update', r => ok(r, view)), replace: record('settings.replace', r => ok(r, view)), mutate: record('settings.mutate', r => ok(r, view)), @@ -697,7 +699,8 @@ describe('config unary surface', () => { const c = client(api) const described = await c.settings.describe({}) - expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } }) + expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } }) + expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } }) const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) expect(updated.result).toEqual({ ok: true, value: view }) const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} }) @@ -718,14 +721,14 @@ describe('config unary surface', () => { expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } }) expect(seen.map(call => call.method)).toEqual([ - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.providers', 'llm.models', ]) - expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) - expect(seen[3]?.payload) + expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) + expect(seen[4]?.payload) .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 }) - expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) + expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) }) it('rejects an invalid credential reference name at the carrier boundary', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 56af007a28..7d41b41612 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -220,7 +220,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, settings: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } } + }, + async openDocument(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, async update(request) { return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } } diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index c8622002e2..236de1c9a7 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { describe, expect, it, vi } from 'vitest' -import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts' +import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' const signal = () => new AbortController().signal @@ -26,6 +26,18 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal)) }) + it('bypasses macOS file associations for text documents', async () => { + const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) + await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run }) + expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal)) + }) + + it('uses the Linux desktop association for text documents', async () => { + const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) + await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run }) + expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal)) + }) + it('opens with Windows Invoke-Item and escapes single quotes', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) @@ -36,6 +48,16 @@ describe('native path opener', () => { ) }) + it('uses the Windows desktop association for text documents', async () => { + const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) + await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"], + expect.any(AbortSignal), + ) + }) + it('opens with Linux xdg-open', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 5c721b33d1..b65861c1ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -36,11 +36,6 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' import { goalEditRequestSchema } from '../src/api/goals.schema.ts' -import { - subagentHistoryRequestSchema, subagentHistoryValueSchema, subagentListEntrySchema, - subagentListRequestSchema, subagentListValueSchema, subagentPromptRequestSchema, - subagentPromptValueSchema, -} from '../src/api/subagents.schema.ts' describe('RpcId', () => { it('brands a raw string at zero runtime cost', () => { @@ -77,16 +72,9 @@ describe('rpcErrorSchema', () => { }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') - expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') - expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable') - expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found') - expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic') - expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable') - expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized') - expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -142,13 +130,7 @@ describe('sessions domain schemas', () => { expect(sessionIdSchema.parse('s1')).toBe('s1') expect(() => sessionIdSchema.parse('')).toThrow() expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true }) - expect(sessionSummarySchema.parse({ - sessionId: 's1', updatedAt: 1, running: true, blank: false, - parentSessionId: 'p', origin: 'subagent', cwd: '/x', - })).toMatchObject({ origin: 'subagent', cwd: '/x' }) - expect(() => sessionSummarySchema.parse({ - sessionId: 's1', updatedAt: 1, running: false, blank: false, origin: 'fork', - })).toThrow() + expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x') // blank is mandatory: a summary without it fails the parse. expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow() const event = sessionEventSchema.parse({ @@ -222,7 +204,6 @@ describe('sessions domain schemas', () => { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'fast', - unlisted: true, reasoning: { efforts: [ { id: 'off', name: 'Off' }, @@ -281,9 +262,6 @@ describe('sessions domain schemas', () => { expect(sessionUpdateQueueRequestSchema.parse({ sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, }).action.kind).toBe('remove') - expect(sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: 'i1', action: { kind: 'steer' }, - }).action.kind).toBe('steer') expect(() => sessionUpdateQueueRequestSchema.parse({ sessionId: 's1', itemId: 'i1', action: { kind: 'promote' }, })).toThrow() @@ -293,45 +271,6 @@ describe('sessions domain schemas', () => { }) }) -describe('subagent domain schemas', () => { - it('validates the direct catalog and addressed history pair', () => { - const child = { - kind: 'child', id: 'c', mode: 'continuable', label: 'worker', - activity: 'running', hasChildren: true, - } - const oneShot = { - kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive', hasChildren: false, - } - const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' } - expect(subagentListEntrySchema.parse(child)).toEqual(child) - expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot) - expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic) - expect(() => subagentListEntrySchema.parse({ - kind: 'child', id: 'missing', mode: 'one-shot', activity: 'inactive', - })).toThrow() - expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' }) - expect(subagentListValueSchema.parse({ - entries: [child, oneShot, diagnostic], parentAvailable: true, - }).entries).toHaveLength(3) - expect(subagentHistoryRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', beforeSeq: 4, maxMessages: 2, - }).beforeSeq).toBe(4) - expect(() => subagentHistoryRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', maxMessages: 0, - })).toThrow() - expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false) - }) - - it('validates continuable prompt content and the accepted inbox identity', () => { - expect(subagentPromptRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', - content: [{ type: 'text', text: '继续' }], - }).childSessionId).toBe('c') - expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1') - expect(() => subagentPromptValueSchema.parse({ route: 'started', taskId: 't2' })).toThrow() - }) -}) - describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) @@ -481,7 +420,11 @@ describe('events frame schemas', () => { { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, { type: 'session/queue', sessionId: 's', items: [ - { id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, + { + id: 'm1', + placement: 'queued', + message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, + }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, @@ -511,15 +454,26 @@ describe('events frame schemas', () => { } }) + it('accepts every queue placement and rejects unknown placements', () => { + const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{ + id: 'm', placement, + message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } }, + }] }) + for (const placement of ['queued', 'steering', 'context']) { + expect(() => muxFrameSchema.parse(item(placement))).not.toThrow() + } + expect(() => muxFrameSchema.parse(item('bogus'))).toThrow() + }) + it('rejects a queue snapshot with malformed items', () => { expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).toThrow() }) it('accepts every host frame branch', () => { const frames = [ - { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p', origin: 'subagent' }, + { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' }, { type: 'host/session-added', sessionId: 's', blank: true }, { type: 'host/session-removed', sessionId: 's' }, { type: 'host/session-status', sessionId: 's', running: true }, @@ -533,9 +487,6 @@ describe('events frame schemas', () => { { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) - expect(() => hostFrameSchema.parse({ - type: 'host/session-added', sessionId: 's', blank: true, origin: 'fork', - })).toThrow() }) }) diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index ea430abe70..49b198d446 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md -README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6 -README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546 +README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6 +README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944 diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index 10d1784590..f1715566c8 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it. -Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes). +Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes). ## Model Experience diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 86ec9f2c3a..9fc8e539d4 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -4,17 +4,17 @@ [目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op)。由于后端以普通条目的形式到达,其 browser half 被 client 模块表发现的方式与配置行完全相同,因此对判定出的选择,seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。 -判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。 +判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。 ## 模型体验 无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 -#### KV 缓存影响 +#### KV Cache 影响 无;该包既不组装也不发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 - **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 888637231e..753f83845a 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 7c2df43ab2..de37aa69ed 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 62384cc0b0e5756e56c1d608252721c506a0915f -README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096 +README.md: 11ddece6f68a752f8afc0392029c752013389d4a +README.zh.md: c73ddeb52264bf7e2d2b9a74f0a7da9194e27c16 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 62384cc0b0..11ddece6f6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience @@ -20,4 +20,4 @@ None; this package neither assembles nor sends a provider request. - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. -- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. +- **Whole-filesystem scope** — there is no per-deployment browse-root restriction. `workspace.create` accepts arbitrary paths, so a root here would be UX scoping rather than a security boundary. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 8f495e1e4d..c73ddeb522 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -2,22 +2,22 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 +[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务原生后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即「不可进入」),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空白段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 -#### KV 缓存影响 +#### KV Cache 影响 无;该包既不组装也不发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 +- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值得付出相应成本为止。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 -- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 +- **全盘可浏览**——没有按部署限定的浏览根。`workspace.create` 接受任意路径,因此这里的根会限定 UX 范围,而不是安全边界。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index ab0cb593dc..58f984159f 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index a458ca94c7..48f9c14309 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -7,7 +7,6 @@ * cordis.yml row; no client code branches on a capability kind. The dialog's * copy is locale-registered here — the flow package owns its own strings. */ -import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -22,8 +21,8 @@ export const inject = ['slots', 'workspaces', 'locale'] /** * Client plugin body: register the dialog's dictionaries and the browse flow - * into both directory-flow holes (declaration-aware deferral — the declaring - * ui-workspace entries may activate later, and an HMR collapse re-declares). + * into both directory-flow holes through `slots.inject()` because the + * ui-workspace entries may activate later or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -78,16 +77,16 @@ export function apply(ctx: ClientContext): void { createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) - ctx.effect(() => { - // One occupant, both holes, as a unit: construction or late conflicts - // (holes declared after rival providers activated) roll the whole pair - // back and fail loud — semantics owned by deferGroupRegistration. - const group = deferGroupRegistration( - ctx.slots, - ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, - BrowseDirectoryFlow, - name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow), - ) - return () => { group.dispose() } - }, 'directory-picker-browse: flow registrations') + // Both declaration lifetimes must be live before the pair installs; the + // generator makes the two registrations one transactional effect. The + // outer/inner nesting order is arbitrary; neither hole has precedence. + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, BrowseDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, BrowseDirectoryFlow) + })) } diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index cde35bff03..e7a07fb63a 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -73,11 +73,11 @@ describe('directory-picker-browse client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) - it('rolls back the first deferral when the second hole is already occupied', async () => { + it('rolls back the outer injection when the second hole is already occupied', async () => { const b = await bench() b.declare() // Foreign occupant in the SECOND registered hole: the pair construction - // throws after the first deferral installed its subscription. + // throws after the outer injection installed its subscription. b.slots.register({ name: HOLES[1] } as never, () => null) const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } @@ -98,19 +98,21 @@ describe('directory-picker-browse client half', () => { } }) - it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => { const b = await bench() const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } process.on('unhandledRejection', onUnhandled) process.on('uncaughtException', onUnhandled) try { - // This provider activates BEFORE any hole exists: both deferrals wait. + // The rival subscribes first, so synchronous declaration notifications + // let it occupy the pair before this provider's waiting injection runs. + b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () { + yield b.slots.register({ name: HOLES[0] } as never, () => null) + yield b.slots.register({ name: HOLES[1] } as never, () => null) + })) await b.ctx.plugin({ inject: [...inject], apply }).await() b.declare() - // A rival occupies both holes ahead of the pending microtask flush. - b.slots.register({ name: HOLES[0] } as never, () => null) - b.slots.register({ name: HOLES[1] } as never, () => null) await new Promise(resolve => setTimeout(resolve, 20)) // The rival keeps both holes; this provider rolled back wholesale and // surfaced the conflict on the fail-loud channel — no partial mix. @@ -198,10 +200,11 @@ describe('directory-picker-browse client half', () => { />, ) // The dialog opened at home; its confirm (browser.open) adopts the listed level. - const openButton = await screen.findByRole('button', { name: 'browser.open' }) - openButton.click() + const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }) + await waitFor(() => { expect(openButton.disabled).toBe(false) }) + fireEvent.click(openButton) expect(props.onPicked).toHaveBeenCalledWith(HOME) - screen.getByRole('button', { name: 'browser.cancel' }).click() + fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' })) expect(props.onCancel).toHaveBeenCalled() expect(props.onError).not.toHaveBeenCalled() }) diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index e798bd6471..9e4a432c5e 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8 -README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f +README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42 +README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0b54c651d4..4dd0c79d08 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). -**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience @@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). +- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure. The browse backend remains the fallback at the composition level. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index e5ac2762a6..8ebf2a6e97 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 -**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 @@ -17,3 +17,4 @@ ## 已知限制与延期工作 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 +- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败。组合层面的回退仍是 browse 后端。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index bafe18e09f..2c75ba0a5e 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -19,21 +19,25 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./worker": { + "types": "./lib/types/win32-dialog-worker.d.ts", + "default": "./lib/worker.cjs" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/worker.cjs", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^" + "@deepseek-ai/dsh-native-command": "workspace:^", + "koffi": "^3.1.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", @@ -50,7 +54,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "tsx": "^4.19.2" }, "dshClient": { "inject": [ diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index 2d5de9a9e7..af220fe5ba 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,7 +7,6 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -20,22 +19,22 @@ export const inject = ['slots', 'workspaces'] /** * Client plugin body: register the renderless native flow into both - * directory-flow holes (declaration-aware deferral — the declaring - * ui-workspace entries may activate later, and an HMR collapse re-declares). + * directory-flow holes through `slots.inject()` because the ui-workspace + * entries may activate later or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) - ctx.effect(() => { - // One occupant, both holes, as a unit: construction or late conflicts - // (holes declared after rival providers activated) roll the whole pair - // back and fail loud — semantics owned by deferGroupRegistration. - const group = deferGroupRegistration( - ctx.slots, - ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, - NativeDirectoryFlow, - name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow), - ) - return () => { group.dispose() } - }, 'directory-picker-native: flow registrations') + // Both declaration lifetimes must be live before the pair installs; the + // generator makes the two registrations one transactional effect. The + // outer/inner nesting order is arbitrary; neither hole has precedence. + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + })) } diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index f131c8bb0c..3a7e3bd05f 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -1,9 +1,10 @@ /** * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host - * display per pick (macOS `osascript`, Windows STA PowerShell - * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable - * when the operator sits at the host's screen; remote deployments compose the + * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; + * Windows opens the modern `IFileOpenDialog` in a spawned child process — a + * koffi-driven COM conversation on the child's main thread). Only viable when + * the operator sits at the host's screen; remote deployments compose the * browse backend instead. * @module @deepseek-ai/dsh-host-directory-picker-native */ diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 2c8e236acc..e25b04ce6c 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,6 +1,7 @@ /** Cross-platform native single-directory chooser behind the native backend's capability. */ import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { pickWin32Directory } from './win32-dialog.ts' /** Testable command boundary; native implementations never invoke a shell. */ export type DirectoryPickerRunner = NativeCommandRunner @@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner export interface DirectoryPickerInternals { platform?: NodeJS.Platform run?: DirectoryPickerRunner + /** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */ + pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null> } function outputPath(stdout: string): string | null { @@ -64,20 +67,13 @@ export async function pickNativeDirectory( } if (platform === 'win32') { - const script = [ - "$ErrorActionPreference = 'Stop'", - 'Add-Type -AssemblyName System.Windows.Forms', - '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', - "$dialog.Description = 'Select Workspace Directory'", - '$dialog.ShowNewFolderButton = $true', - '$result = $dialog.ShowDialog()', - 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', - ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - ' [Console]::WriteLine($dialog.SelectedPath)', - '}', - ].join('; ') - const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) + // The koffi-backed IFileOpenDialog child process — the modern picker with + // per-monitor-v2 DPI and abort support. koffi is a packaged dependency + // whose availability the install guarantees, so there is no fallback + // tier: any failure surfaces as-is (the former PowerShell chain was + // removed — see the simplification Agent Note). + const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory + return await pickDialog(signal) } if (platform === 'linux') { diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts new file mode 100644 index 0000000000..654bbc5a74 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -0,0 +1,195 @@ +/** + * koffi-backed Win32 bindings for the folder dialog: the COM vtable calls + * behind {@link Win32DialogBindings} plus the cross-thread window closer the + * driver uses to service aborts. The module loads on every platform; koffi + * itself is imported lazily inside each function, so non-Windows processes + * never load it — the same containment as the repo's other `win32.ts` + * modules. + * + * The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and + * IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is + * frozen Windows ABI since Vista; slots are offsets into the vtable at the + * object's first pointer. + */ + +import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts' + +interface KoffiFunction { (...args: unknown[]): unknown } +interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction } +interface Koffi { + load(path: string): KoffiLibrary + proto(declaration: string): unknown + pointer(type: unknown): unknown + call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown + decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown + register(fn: (...args: unknown[]) => unknown, type: unknown): unknown + unregister(callback: unknown): void + sizeof(type: string): number + view(ref: unknown, len: number): ArrayBuffer +} + +/** + * Read a NUL-terminated UTF-16 string at a native address. koffi's + * `_Out_ void **` out-params surface a raw address, and + * `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash + * on real Windows — so view the memory directly instead. + */ +function readUtf16(koffi: Koffi, address: unknown): string { + const bytes = Buffer.from(koffi.view(address, 32768)) + let end = 0 + while (end + 1 < bytes.length && bytes[end] !== 0) end += 2 + return bytes.toString('utf16le', 0, end) +} + +const COINIT_APARTMENTTHREADED = 0x2 +const CLSCTX_INPROC_SERVER = 0x1 +const SIGDN_FILESYSPATH = 0x80058000 | 0 +/** + * Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10 + * 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext` + * returns NULL for an unsupported context instead of throwing, so the caller + * cascades to the best one the host accepts; DPI stays a cosmetic + * best-effort — an unsupported host still gets the modern dialog. + */ +const DPI_AWARENESS_CONTEXTS = [-4, -3, -2] +const WM_CLOSE = 0x10 + +/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */ +const SLOT_RELEASE = 2 +const SLOT_SHOW = 3 +const SLOT_SET_OPTIONS = 9 +const SLOT_SET_TITLE = 17 +const SLOT_GET_RESULT = 20 +/** IShellItem vtable slot for `GetDisplayName`. */ +const SLOT_GET_DISPLAY_NAME = 5 + +/** + * Encode a canonical GUID string as its 16 little-endian bytes. + * @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form. + * @returns the in-memory GUID bytes CoCreateInstance expects. + */ +function guidBytes(text: string): Buffer { + const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray + const bytes = Buffer.alloc(16) + bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0) + bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4) + bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6) + Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8) + return bytes +} + +const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7') +const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960') + +/** + * Load koffi and expose the dialog bindings for this thread. + * @returns the bindings {@link runFolderDialog} sequences against. + */ +export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> { + const koffi = (await import('koffi')).default as unknown as Koffi + const ole32 = koffi.load('ole32.dll') + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + + // Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64, + // 4 on ia32 — koffi reports the running process's width. + const pointerSize = koffi.sizeof('void *') + const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32']) + const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', []) + const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *']) + const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *']) + const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) + + const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)') + const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)') + const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)') + const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)') + const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)') + const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)') + + /** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */ + const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => { + const vtable = koffi.decode(self, 'void *') + const fn = koffi.decode(vtable, slot * pointerSize, 'void *') + return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number + } + + return { + setThreadDpiAwareness: () => { + let setContext: KoffiFunction + try { + setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) + } catch { + // Symbol absent (pre-1607 Windows): no per-thread DPI control exists. + // Proceed anyway — the cost is a blurry dialog above 100 % scaling on + // museum hosts, and the modern picker still beats dropping to the + // legacy 5.1 tree over a cosmetic concern. + return + } + for (const context of DPI_AWARENESS_CONTEXTS) { + if (setContext(context) !== null) return + } + // Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol + // exists); if a host ever refuses everything, the dialog still works — + // just without a DPI opt-in. + }, + coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, + coUninitialize: () => { + coUninitialize() + }, + currentThreadId: () => getCurrentThreadId() as number, + createFolderDialog: (): Win32FolderDialog => { + const out = Buffer.alloc(pointerSize) + const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number + if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`) + const dialog = koffi.decode(out, 'void *') + return { + setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options), + setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title), + show: () => method(dialog, SLOT_SHOW, protoShow)(null), + resultPath: () => { + const itemOut: unknown[] = [null] + const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut) + if (gotItem < 0) return { hr: gotItem } + const item = itemOut[0] + try { + const nameOut: unknown[] = [null] + const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut) + if (gotName < 0) return { hr: gotName } + const path = readUtf16(koffi, nameOut[0]) + coTaskMemFree(nameOut[0]) + return { hr: gotName, path } + } finally { + method(item, SLOT_RELEASE, protoRelease)() + } + }, + release: () => { + method(dialog, SLOT_RELEASE, protoRelease)() + }, + } + }, + } +} + +/** + * Post `WM_CLOSE` to every window of a native thread — the driver's abort + * lever against the worker blocked inside `Show`, after which `Show` returns + * `HRESULT_CANCELLED` and the worker unwinds normally. + * @param threadId - the dialog thread's native id (from the `showing` notice). + */ +export async function closeThreadWindows(threadId: number): Promise<void> { + const koffi = (await import('koffi')).default as unknown as Koffi + const user32 = koffi.load('user32.dll') + const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) + const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr']) + const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') + const callback = koffi.register((hwnd: unknown) => { + postMessageW(hwnd, WM_CLOSE, 0, 0) + return 1 + }, koffi.pointer(protoEnumProc)) + try { + enumThreadWindows(threadId, callback, 0) + } finally { + koffi.unregister(callback) + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts new file mode 100644 index 0000000000..7a60ab05ed --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -0,0 +1,33 @@ +/** + * Real-process half of the Win32 dialog driver: spawn the dialog child + * process (source or built plane) and close a dialog thread's windows. The + * module itself loads everywhere (the import chain from native-picker.ts is + * static); what stays win32-only is koffi, imported dynamically inside the + * bindings' functions. The driver's logic is tested against fakes of this + * surface instead. + */ + +import { spawn, type StdioOptions } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' + +/** + * Spawn the dialog child process. Built consumers launch the bundled CJS + * entry next to this module under plain node; unbuilt (source) consumers + * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is + * the child's first window, so Windows activates it without a foreground + * call. + * @param data - the child payload (dialog title). + * @returns the spawned child process. + */ +export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn> { + const env = { ...process.env, DSH_DIALOG_TITLE: data.title } + const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] + /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ + if (!import.meta.url.endsWith('.ts')) { + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) + } + return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) +} + +export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts new file mode 100644 index 0000000000..aa9d1445c4 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -0,0 +1,132 @@ +/** + * Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM + * conversation over an injectable bindings seam, so every outcome path + * (selection, cancellation, HRESULT failure, cleanup ordering) is testable on + * any platform. The koffi-backed bindings live in + * `win32-dialog-bindings.ts`, which only a real win32 process ever loads. + */ + +/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */ +export const HRESULT_CANCELLED = 0x800704c7 | 0 + +/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */ +export const FOS_PICKFOLDERS = 0x20 +/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */ +export const FOS_FORCEFILESYSTEM = 0x40 +/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */ +export const FOS_NOCHANGEDIR = 0x8 + +/** One created folder dialog: the vtable calls the sequencing needs. */ +export interface Win32FolderDialog { + /** + * `IFileDialog::SetOptions`. + * @param options - the `FOS_*` flag union to apply. + * @returns the call's HRESULT. + */ + setOptions(options: number): number + /** + * `IFileDialog::SetTitle`. + * @param title - the dialog title text. + * @returns the call's HRESULT. + */ + setTitle(title: string): number + /** + * `IModalWindow::Show` with no owner window; blocks the calling thread + * until the user selects or dismisses. + * @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal). + */ + show(): number + /** + * `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`, + * releasing the shell item and freeing the COM string. + * @returns the call chain's HRESULT and, on success, the selected path. + */ + resultPath(): { hr: number; path?: string } + /** Release the dialog's COM reference. */ + release(): void +} + +/** The thread-level native surface the dialog sequencing runs against. */ +export interface Win32DialogBindings { + /** + * Opt the calling thread into the best supported DPI awareness + * (per-monitor-v2, then per-monitor, then system-aware), checking each + * call's result. Best-effort on purpose: a host accepting none of them + * (or lacking the API, pre-1607) still shows the modern dialog — possibly + * blurry above 100 % scaling — because a cosmetic degradation must not + * cost the tier. + */ + setThreadDpiAwareness(): void + /** + * `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread. + * @returns the call's HRESULT (`S_FALSE` re-entry is still a success). + */ + coInitializeSta(): number + /** + * `CoUninitialize` on the calling thread — COM requires one pairing call + * for every successful (including `S_FALSE`) `CoInitializeEx`, even on a + * thread that exits right after the conversation. + */ + coUninitialize(): void + /** + * `CoCreateInstance(CLSID_FileOpenDialog)`. + * @returns the created dialog surface; throws when creation fails. + */ + createFolderDialog(): Win32FolderDialog + /** + * `GetCurrentThreadId` — the native id a driver needs to close this + * thread's windows from outside. + * @returns the calling thread's native id. + */ + currentThreadId(): number +} + +/** + * Throw when an HRESULT signals failure. + * @param hr - the HRESULT to check. + * @param what - the failing call's name for the error message. + * @returns the (successful) HRESULT unchanged. + */ +function check(hr: number, what: string): number { + if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`) + return hr +} + +/** + * Run one modal folder-picker conversation on the calling thread: DPI opt-in, + * STA init, dialog creation, `Show`, and result extraction, releasing the + * dialog on every path. + * @param bindings - the native surface (koffi-backed in production, fakes in tests). + * @param title - the dialog title text. + * @param onShowing - called with the native thread id immediately before the + * blocking `Show`, so a driver on another thread can close the dialog. + * @returns the selected filesystem path, or null when the user cancels. + */ +export function runFolderDialog( + bindings: Win32DialogBindings, + title: string, + onShowing: (threadId: number) => void, +): string | null { + bindings.setThreadDpiAwareness() + check(bindings.coInitializeSta(), 'CoInitializeEx') + // From here the apartment is initialized (S_OK or S_FALSE) and must be + // uninitialized exactly once on every path. + try { + const dialog = bindings.createFolderDialog() + try { + check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions') + check(dialog.setTitle(title), 'SetTitle') + onShowing(bindings.currentThreadId()) + const shown = dialog.show() + if (shown === HRESULT_CANCELLED) return null + check(shown, 'Show') + const result = dialog.resultPath() + check(result.hr, 'GetResult') + return result.path as string + } finally { + dialog.release() + } + } finally { + bindings.coUninitialize() + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts new file mode 100644 index 0000000000..0b422b3ca7 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -0,0 +1,52 @@ +/** + * Child-process entry for the Win32 folder dialog: blocks THIS process + * inside the modal `Show` so the host event loop stays live, reporting over + * the IPC channel. Spawned as a child process (not a worker thread) so the + * dialog is the process's first window and Windows activates it without a + * manual foreground call. Protocol: `{kind:'showing',threadId}` right + * before the blocking call (the driver's abort lever needs the native + * thread id), then exactly one of `{kind:'done',path}` or + * `{kind:'error',message}`. + */ + +import { loadWin32DialogBindings } from './win32-dialog-bindings.ts' +import { runFolderDialog } from './win32-dialog-logic.ts' + +/** The driver-to-child payload: the dialog title (passed via env). */ +export interface Win32DialogWorkerData { title: string } + +/** One notice or outcome posted back to the driver. */ +export type Win32DialogWorkerMessage = + | { kind: 'showing'; threadId: number } + | { kind: 'done'; path: string | null } + | { kind: 'error'; message: string } + +const title = process.env.DSH_DIALOG_TITLE ?? '' +if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required') +if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel') +// node's internal `send` reads `this.connected`, so bind the receiver. +const send = process.send.bind(process) + +const post = (message: Win32DialogWorkerMessage): void => { + // Flush before closing the channel; the process exits when the loop drains. + /* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */ + send(message, () => { if (process.connected) process.disconnect() }) +} + +// A settled driver (or a dead parent) must not orphan a dialog still on screen. +/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */ +process.on('disconnect', () => process.exit(0)) + +// No top-level await: the built worker ships as CJS, which cannot carry TLA. +void (async () => { + try { + const bindings = await loadWin32DialogBindings() + const path = runFolderDialog(bindings, title, (threadId) => { + post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) + }) + post({ kind: 'done', path } satisfies Win32DialogWorkerMessage) + } catch (error: unknown) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + post({ kind: 'error', message } satisfies Win32DialogWorkerMessage) + } +})() diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts new file mode 100644 index 0000000000..247d9a1733 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -0,0 +1,159 @@ +/** + * Main-thread driver for the Win32 folder dialog: spawns the dialog child + * process (which blocks inside the modal `Show`), maps its message protocol + * onto a promise, and services aborts by posting `WM_CLOSE` to the dialog + * thread's windows until the child reports back. The real process/window + * surface is injectable so every driver path is testable on any platform. + */ + +import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' +import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' + +/** The child-process surface the driver drives (satisfied by `node:child_process`). */ +export interface Win32DialogWorkerLike { + /** + * Subscribe to a child-process event. + * @param event - `message`, `error`, or `exit`. + * @param listener - the event consumer. + */ + on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + /** + * Force-stop the child; the abort path's last resort when `WM_CLOSE` + * never lands (e.g. the dialog window was never created). + * @returns whether a kill signal was delivered. + */ + kill(): boolean + /** + * Release the event-loop reference. Called once the pick settles so a + * child stuck in the native modal call never blocks process exit. + */ + unref?(): void +} + +/** Injectable process surface for deterministic driver tests. */ +export interface Win32DialogInternals { + /** Replaces the real child spawn (`win32-dialog-host.ts`). */ + spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike + /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ + closeThreadWindows?: (threadId: number) => Promise<void> + /** Abort-service cadence override so tests never wait wall-clock time. */ + closeRetryMs?: number +} + +/** The dialog title every host shows. */ +export const DIALOG_TITLE = 'Select Workspace Directory' + +/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */ +const CLOSE_RETRY_MS = 150 +/** Abort-service attempts before force-terminating the worker. */ +const CLOSE_MAX_ATTEMPTS = 20 + +/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */ +function assertNever(value: never): never { + throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`) +} +/* v8 ignore stop */ + +/** + * Open the modern Win32 folder picker off the event loop. + * @param signal - caller lifetime; abort closes the dialog and rejects. + * @param internals - worker/window seams for deterministic tests. + * @returns the selected path, or null when the user cancels. + */ +export async function pickWin32Directory( + signal: AbortSignal, + internals: Win32DialogInternals = {}, +): Promise<string | null> { + if (signal.aborted) throw new Error('native directory picker aborted') + const spawnWorker = internals.spawnWorker ?? spawnDialogWorker + const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows + const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS + + const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE }) + let dialogThreadId: number | undefined + let closeTimer: NodeJS.Timeout | undefined + let settled = false + + return await new Promise<string | null>((resolve, reject) => { + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + if (closeTimer !== undefined) clearInterval(closeTimer) + signal.removeEventListener('abort', onAbort) + worker.unref?.() + outcome() + } + + const postClose = (): void => { + // Before `showing` there is no window to close; the budget below still + // runs so a child that never reports cannot dangle the pick. A + // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is + // discarded: the interval retries it and kill is the backstop. + if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) + } + + // Sole caller: the once-registered abort listener, so no re-entry guard. + const serviceAbort = (): void => { + let attempts = 0 + // The `showing` notice precedes the blocking `Show`, so the very first + // WM_CLOSE can race the window's creation; re-post until the child + // reports back, then force-kill as a last resort. The budget is + // unconditional — an abort before `showing` (child hung in koffi or + // COM init) still ends in kill instead of a dangling promise. + closeTimer = setInterval(() => { + attempts += 1 + if (attempts > CLOSE_MAX_ATTEMPTS) { + settle(() => { + worker.kill() + reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)')) + }) + return + } + postClose() + }, closeRetryMs) + postClose() + } + + const onAbort = (): void => { + serviceAbort() + } + signal.addEventListener('abort', onAbort, { once: true }) + + worker.on('message', (message: Win32DialogWorkerMessage) => { + switch (message.kind) { + case 'showing': + dialogThreadId = message.threadId + // An abort that raced ahead of this notice now has a window to hit. + if (signal.aborted) postClose() + return + case 'done': + settle(() => { + if (signal.aborted) reject(new Error('native directory picker aborted')) + else resolve(message.path) + }) + return + case 'error': + settle(() => { + reject(new Error(`win32 folder dialog failed: ${message.message}`)) + }) + return + /* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */ + default: + assertNever(message) + } + }) + worker.on('error', (error: Error) => { + settle(() => { + reject(error) + }) + }) + worker.on('exit', () => { + settle(() => { + reject(new Error('win32 folder dialog worker exited before reporting a result')) + }) + }) + }) +} diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..2c3b793a7d --- /dev/null +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -0,0 +1,34 @@ +/** + * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker + * shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its + * real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would + * open), where the win32-only smoke in win32-dialog.spec.ts covers the + * source plane instead. Skips until a build produces the artifact. + */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) + +describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { + it('loads under plain node and reports the native-surface failure', async () => { + const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => { + const child = spawn(process.execPath, [builtWorker], { + env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + child.on('message', resolve) + child.on('error', reject) + child.on('exit', (code) => { + reject(new Error(`worker exited (${code}) before reporting`)) + }) + }) + expect(message.kind).toBe('error') + expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i) + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index ecddf84eb4..34eb5e40b4 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -56,7 +56,16 @@ describe('directory-picker-native client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) - it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + it('fails loudly instead of deduplicating a duplicate package row', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const duplicate = b.ctx.plugin({ inject: [...inject], apply }) + await expect(duplicate.await()).rejects.toThrow(/already has a registration/) + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + }) + + it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => { const b = await bench() const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } @@ -64,15 +73,14 @@ describe('directory-picker-native client half', () => { process.on('unhandledRejection', onUnhandled) process.on('uncaughtException', onUnhandled) try { - // This provider activates BEFORE any hole exists: both deferrals wait. - // (Duplicate rows of the SAME package converge silently — the deferral - // skips a hole its own component already occupies; the conflict needs - // a rival provider.) + // The rival subscribes first, so synchronous declaration notifications + // let it occupy the pair before this provider's waiting injection runs. + b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () { + yield b.slots.register({ name: HOLES[0] } as never, () => null) + yield b.slots.register({ name: HOLES[1] } as never, () => null) + })) await b.ctx.plugin({ inject: [...inject], apply }).await() b.declare() - // A rival occupies both holes ahead of the pending microtask flush. - b.slots.register({ name: HOLES[0] } as never, () => null) - b.slots.register({ name: HOLES[1] } as never, () => null) await new Promise(resolve => setTimeout(resolve, 20)) // The rival keeps both holes; this provider rolled back wholesale and // surfaced the conflict on the fail-loud channel — no partial mix. @@ -97,11 +105,11 @@ describe('directory-picker-native client half', () => { } }) - it('rolls back the first deferral when the second hole is already occupied', async () => { + it('rolls back the outer injection when the second hole is already occupied', async () => { const b = await bench() b.declare() // Foreign occupant in the SECOND registered hole: the pair construction - // throws after the first deferral installed its subscription. + // throws after the outer injection installed its subscription. b.slots.register({ name: HOLES[1] } as never, () => null) const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 87e25ff877..66f4845b4b 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,3 +1,9 @@ +/** + * Native picker tier selection and the execFile adapter: the Win32 dialog + * primary (failures surface as-is, no fallback tier), the abort rule, and + * the POSIX command tiers (osascript, Zenity → KDialog). + */ + type ExecFileCallback = ( error: (Error & { code?: string | number }) | null, stdout: string, @@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error { const signal = () => new AbortController().signal +/** A Win32 dialog that always fails — the no-fallback case. */ +const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') } + describe('native directory picker', () => { it('uses the macOS folder chooser and maps user cancellation to null', async () => { const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) @@ -46,46 +55,79 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { - const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') - expect(run).toHaveBeenCalledWith( - 'powershell.exe', - expect.arrayContaining(['-NoProfile', '-STA', '-Command']), - expect.any(AbortSignal), - ) - expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'") - run.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() - run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') + it('uses the Win32 dialog and never spawns a command when it answers', async () => { + const run = vi.fn<DirectoryPickerRunner>() + const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected') + pickWin32Dialog.mockResolvedValueOnce(null) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull() + expect(run).not.toHaveBeenCalled() + }) + + it('surfaces the Win32 dialog failure with no fallback', async () => { + const run = vi.fn<DirectoryPickerRunner>() + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })) + .rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() + }) + + it('wires the real Win32 dialog as the default tier', async () => { + // A pre-aborted signal makes the DEFAULT dialog deterministic on every + // host: pickWin32Directory throws before spawning any worker or window. + const abort = new AbortController() + abort.abort() + const run = vi.fn<DirectoryPickerRunner>() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })) + .rejects.toThrow('native directory picker aborted') + expect(run).not.toHaveBeenCalled() + }) + + it('does not fall back when the caller aborted the dialog', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn<DirectoryPickerRunner>() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() }) it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'C:\\work\\default\r\n', '') + callback(null, '/home/test/project\n', '') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project') const [command, args, options] = execFileMock.mock.calls[0]! - expect(command).toBe('powershell.exe') - expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(command).toBe('zenity') + expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory'])) expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) - const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + // A non-cancellation command failure surfaces as-is with its cause and + // captured stdio attached; no tier masks or rewraps it. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(commandError, 'partial output', 'failure details') + callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ - message: 'powershell failed', cause: commandError, code: 7, + const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error) + expect(surfaced).toMatchObject({ + message: 'zenity failed', code: 7, stdout: 'partial output', stderr: 'failure details', }) + expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error) }) it('uses the current process platform when no platform override is supplied', async () => { + // Deterministic on every host: the win32 tier answers from the dialog, + // the POSIX tiers from the command runner. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + const pickWin32Dialog = async (): Promise<string | null> => 'C:\\default\\platform' + const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform' + await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected) + }) + + it('maps empty command output to cancellation', async () => { + const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull() }) it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts new file mode 100644 index 0000000000..b8ff4c3f1a --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -0,0 +1,354 @@ +/** + * The koffi-backed bindings against a mocked `koffi` module (the same + * technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory + * COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch, + * result extraction, memory hygiene, and the WM_CLOSE poster covered on every + * host. The worker entry is exercised the same way with a mocked process + * boundary (env title + `process.send`). Real-COM behavior is pinned by the + * win32-only smoke in win32-dialog.spec.ts. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 +const WM_CLOSE = 0x10 +/** + * Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer + * sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against + * this width (the win32-ia32 bug class). + */ +const FAKE_POINTER_SIZE = 4 + +interface ComWorld { + coInitHr: number + coCreateHr: number + showHr: number + getResultHr: number + getDisplayNameHr: number + hasThreadDpi: boolean + /** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */ + supportedDpiContexts: number[] + enumThrows: boolean + path: string + titles: string[] + options: number[] + dpiContexts: unknown[] + freed: unknown[] + released: string[] + posted: { hwnd: unknown; message: number }[] + registered: number + unregistered: number + uninitialized: number +} + +function comWorld(overrides: Partial<ComWorld> = {}): ComWorld { + return { + coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0, + hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false, + path: 'C:\\选中\\directory', + titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], + registered: 0, unregistered: 0, uninitialized: 0, + ...overrides, + } +} + +/** Sentinel pointer objects standing in for native addresses. */ +interface FakePtr { kind: string; [key: string]: unknown } + +function installFakeKoffi(world: ComWorld): void { + const dialogPtr: FakePtr = { kind: 'dialog' } + const itemPtr: FakePtr = { kind: 'item' } + const namePtr: FakePtr = { kind: 'name', text: world.path } + const outBuffers = new Map<unknown, FakePtr>() + + const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => { + if (self.kind === 'dialog') { + switch (slot) { + case 9: world.options.push(args[0] as number); return 0 + case 17: world.titles.push(args[0] as string); return 0 + case 3: return world.showHr + case 20: { + if (world.getResultHr < 0) return world.getResultHr + ;(args[0] as unknown[])[0] = itemPtr + return 0 + } + case 2: world.released.push('dialog'); return 0 + default: throw new Error(`unexpected dialog slot ${slot}`) + } + } + switch (slot) { + case 5: { + if (world.getDisplayNameHr < 0) return world.getDisplayNameHr + ;(args[1] as unknown[])[0] = namePtr + return 0 + } + case 2: world.released.push('item'); return 0 + default: throw new Error(`unexpected item slot ${slot}`) + } + } + + vi.doMock('koffi', () => ({ + default: { + load: (dll: string) => ({ + func: (_convention: string, name: string, _result: string, _args: string[]) => { + switch (name) { + case 'CoInitializeEx': return () => world.coInitHr + case 'CoUninitialize': return () => { world.uninitialized += 1 } + case 'CoCreateInstance': return (...args: unknown[]) => { + if (world.coCreateHr < 0) return world.coCreateHr + // The out-pointer must be allocated at the fake's pointer width. + if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) { + throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`) + } + outBuffers.set(args[4], dialogPtr) + return 0 + } + case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) } + case 'GetCurrentThreadId': return () => 31337 + case 'SetThreadDpiAwarenessContext': { + if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`) + return (context: unknown) => { + world.dpiContexts.push(context) + return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null + } + } + case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => { + if (world.enumThrows) throw new Error('EnumThreadWindows refused') + callback.fn({ kind: 'hwnd', n: 1 }, lparam) + callback.fn({ kind: 'hwnd', n: 2 }, lparam) + return 1 + } + case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 } + default: throw new Error(`unexpected native import ${dll}/${name}`) + } + }, + }), + proto: (declaration: string) => ({ declaration }), + pointer: (type: unknown) => type, + sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, + view: (value: unknown, len: number): ArrayBuffer => { + const bytes = Buffer.alloc(len) + bytes.write((value as FakePtr).text as string, 'utf16le') + return bytes.buffer + }, + register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, + unregister: () => { world.unregistered += 1 }, + decode: (value: unknown, offsetOrType: unknown): unknown => { + if (offsetOrType === 'str16') return (value as FakePtr).text + if (typeof offsetOrType === 'number') { + // Vtable slot read: offsets must be multiples of the fake width. + if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`) + const owner = (value as { owner: FakePtr }).owner + return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) } + } + // decode(x, 'void *'): out-buffer read or vtable read. + if (outBuffers.has(value)) return outBuffers.get(value) + return { owner: value as FakePtr } + }, + call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args), + }, + })) +} + +async function loadBindingsModule(): Promise<typeof import('../src/win32-dialog-bindings.ts')> { + return await import('../src/win32-dialog-bindings.ts') +} + +afterEach(() => { + vi.doUnmock('koffi') + vi.doUnmock('node:worker_threads') + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() +}) + +describe('loadWin32DialogBindings over the fake COM world', () => { + it('drives the full selection conversation with memory hygiene', async () => { + const world = comWorld() + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + const showing = vi.fn() + + expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4]) + expect(world.titles).toEqual(['选择工作区目录']) + expect(world.options).toHaveLength(1) + expect(showing).toHaveBeenCalledWith(31337) + expect(world.freed).toHaveLength(1) + expect(world.released).toEqual(['item', 'dialog']) + expect(world.uninitialized).toBe(1) + }) + + it('maps dismissal and the S_FALSE CoInitializeEx', async () => { + const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 }) + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(world.released).toEqual(['dialog']) + expect(world.uninitialized).toBe(1) + }) + + it('cascades DPI contexts to the first the host accepts', async () => { + const world = comWorld({ supportedDpiContexts: [-3] }) + installFakeKoffi(world) + const bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4, -3]) + }) + + it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => { + // DPI is a cosmetic best-effort: the modern dialog still opens. + const rejecting = comWorld({ supportedDpiContexts: [] }) + installFakeKoffi(rejecting) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(rejecting.dpiContexts).toEqual([-4, -3, -2]) + + vi.doUnmock('koffi') + vi.resetModules() + const preThreadDpi = comWorld({ hasThreadDpi: false }) + installFakeKoffi(preThreadDpi) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(preThreadDpi.dpiContexts).toEqual([]) + }) + + it('surfaces creation and extraction failures as HRESULT errors', async () => { + const creationWorld = comWorld({ coCreateHr: E_FAIL }) + installFakeKoffi(creationWorld) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005') + + vi.doUnmock('koffi') + vi.resetModules() + const resultWorld = comWorld({ getResultHr: E_FAIL }) + installFakeKoffi(resultWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + expect(resultWorld.released).toEqual(['dialog']) + + vi.doUnmock('koffi') + vi.resetModules() + const nameWorld = comWorld({ getDisplayNameHr: E_FAIL }) + installFakeKoffi(nameWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + // The shell item is released even when its display name cannot be read. + expect(nameWorld.released).toEqual(['item', 'dialog']) + expect(nameWorld.freed).toHaveLength(0) + }) +}) + +describe('closeThreadWindows over the fake COM world', () => { + it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => { + const world = comWorld() + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await closeThreadWindows(777) + expect(world.posted).toEqual([ + { hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE }, + { hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE }, + ]) + expect(world.registered).toBe(1) + expect(world.unregistered).toBe(1) + }) + + it('unregisters the callback even when the enumeration itself throws', async () => { + const world = comWorld({ enumThrows: true }) + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused') + expect(world.unregistered).toBe(1) + }) +}) + +describe('the worker entry over a mocked process boundary', () => { + const originalSend = process.send?.bind(process) + const originalTitle = process.env.DSH_DIALOG_TITLE + + const installBoundary = (): { posted: { kind: string; message?: string }[] } => { + const posted: { kind: string; message?: string }[] = [] + process.env.DSH_DIALOG_TITLE = 'Pick' + // Never invoke the post callback: it runs the worker's disconnect(), and + // this process is IPC-connected under the forks pool — severing vitest's + // own channel would kill the test worker. The real close lifecycle + // belongs to built-worker.e2e.ts. + ;(process as { send?: unknown }).send = (message: { kind: string }) => { + posted.push(message) + return true + } + return { posted } + } + + afterEach(() => { + delete (process as { send?: unknown }).send + if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend + if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE + else process.env.DSH_DIALOG_TITLE = originalTitle + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() + }) + + it('posts showing then done for a completed conversation', async () => { + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => ({ + setThreadDpiAwareness: () => undefined, + coInitializeSta: () => 0, + coUninitialize: () => undefined, + currentThreadId: () => 11, + createFolderDialog: () => ({ + setOptions: () => 0, + setTitle: () => 0, + show: () => 0, + resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }), + release: () => undefined, + }), + }), + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toEqual([ + { kind: 'showing', threadId: 11 }, + { kind: 'done', path: 'C:\\from-worker' }, + ]) + }) + + it('posts the failure message when the native surface cannot load', async () => { + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw new Error('no ole32 here') }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toHaveLength(1) + expect(posted[0]?.kind).toBe('error') + expect(posted[0]?.message).toContain('no ole32 here') + }) + + it('stringifies stackless and non-Error failures', async () => { + const stackless = new Error('bare message') + delete stackless.stack + for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) { + vi.resetModules() + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw thrown }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted[0]?.message).toBe(expected) + } + }) + + it('refuses to run without the dialog title', async () => { + delete process.env.DSH_DIALOG_TITLE + ;(process as { send?: unknown }).send = () => true + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required') + }) + + it('refuses to run outside a child process', async () => { + process.env.DSH_DIALOG_TITLE = 'Pick' + delete (process as { send?: unknown }).send + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process') + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts new file mode 100644 index 0000000000..718c93c2c0 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts @@ -0,0 +1,98 @@ +/** + * The COM conversation's sequencing against fake bindings: outcome mapping + * (selection / cancellation / HRESULT failures at every step) and the + * release-on-every-path guarantee, all platform-independent. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED, + runFolderDialog, type Win32DialogBindings, type Win32FolderDialog, +} from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 + +interface FakeWorld { + bindings: Win32DialogBindings + dpi: ReturnType<typeof vi.fn> + createDialog: ReturnType<typeof vi.fn> + uninitialize: ReturnType<typeof vi.fn> + dialog: { + setOptions: ReturnType<typeof vi.fn> + setTitle: ReturnType<typeof vi.fn> + show: ReturnType<typeof vi.fn> + resultPath: ReturnType<typeof vi.fn> + release: ReturnType<typeof vi.fn> + } +} + +function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorld { + const dialog = { + setOptions: vi.fn(() => 0), + setTitle: vi.fn(() => 0), + show: vi.fn(() => 0), + resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })), + release: vi.fn(), + ...overrides, + } + const dpi = vi.fn() + const createDialog = vi.fn(() => dialog) + const uninitialize = vi.fn() + const bindings: Win32DialogBindings = { + setThreadDpiAwareness: dpi, + coInitializeSta: vi.fn(() => coInit), + coUninitialize: uninitialize, + createFolderDialog: createDialog, + currentThreadId: vi.fn(() => 4242), + } + return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] } +} + +describe('runFolderDialog', () => { + it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => { + const { bindings, dpi, dialog, uninitialize } = world() + const showing = vi.fn() + expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录') + expect(dpi).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number) + expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR) + expect(dialog.setTitle).toHaveBeenCalledWith('Pick') + expect(showing).toHaveBeenCalledWith(4242) + expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number) + expect(dialog.release).toHaveBeenCalledOnce() + }) + + it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => { + const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) }) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(dialog.resultPath).not.toHaveBeenCalled() + expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + }) + + it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => { + const { bindings } = world({}, 1) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录') + }) + + it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => { + const { bindings, createDialog, uninitialize } = world({}, E_FAIL) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005') + expect(createDialog).not.toHaveBeenCalled() + // A failed CoInitializeEx must NOT be paired with CoUninitialize. + expect(uninitialize).not.toHaveBeenCalled() + }) + + it.each([ + ['SetOptions', { setOptions: vi.fn(() => E_FAIL) }], + ['SetTitle', { setTitle: vi.fn(() => E_FAIL) }], + ['Show', { show: vi.fn(() => E_FAIL) }], + ['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }], + ] satisfies [string, Partial<Win32FolderDialog>][])('releases the dialog and apartment when %s fails', (what, overrides) => { + const { bindings, dialog, uninitialize } = world(overrides) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`) + expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts new file mode 100644 index 0000000000..8e7d6951b8 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -0,0 +1,163 @@ +/** + * Driver tests: the child-process message protocol mapped onto the promise, + * the WM_CLOSE abort service (including the show-race retry and the kill + * last resort) against fakes, plus the real spawn plumbing — POSIX hosts + * prove the default path rejects cleanly (koffi cannot load ole32 there), + * and win32 hosts briefly open and auto-abort a real dialog. + */ + +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +class FakeWorker extends EventEmitter implements Win32DialogWorkerLike { + kill = vi.fn(() => true) + post(message: Win32DialogWorkerMessage): void { + this.emit('message', message) + } +} + +interface Harness { + worker: FakeWorker + internals: Win32DialogInternals + close: ReturnType<typeof vi.fn> +} + +function harness(overrides: Partial<Win32DialogInternals> = {}): Harness { + const worker = new FakeWorker() + const close = vi.fn(async () => undefined) + return { + worker, + close, + internals: { + spawnWorker: () => worker, + closeThreadWindows: close, + closeRetryMs: 1, + ...overrides, + }, + } +} + +const live = (): AbortSignal => new AbortController().signal + +describe('pickWin32Directory', () => { + it('resolves the selected path and the cancellation null', async () => { + const first = harness() + const picked = pickWin32Directory(live(), first.internals) + first.worker.post({ kind: 'showing', threadId: 7 }) + first.worker.post({ kind: 'done', path: 'C:\\picked' }) + await expect(picked).resolves.toBe('C:\\picked') + expect(first.close).not.toHaveBeenCalled() + + const second = harness() + const cancelled = pickWin32Directory(live(), second.internals) + second.worker.post({ kind: 'done', path: null }) + await expect(cancelled).resolves.toBeNull() + }) + + it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { + const reported = harness() + const failing = pickWin32Directory(live(), reported.internals) + reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' }) + await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed') + + const crashed = harness() + const crashing = pickWin32Directory(live(), crashed.internals) + crashed.worker.emit('error', new Error('worker blew up')) + await expect(crashing).rejects.toThrow('worker blew up') + + const silent = harness() + const exiting = pickWin32Directory(live(), silent.internals) + silent.worker.emit('exit', 0) + await expect(exiting).rejects.toThrow('exited before reporting a result') + }) + + it('settles once: a late exit after the result is inert', async () => { + const { worker, internals } = harness() + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'done', path: 'C:\\once' }) + worker.emit('exit', 0) + await expect(picked).resolves.toBe('C:\\once') + }) + + it('throws immediately on an already-aborted signal without spawning', async () => { + const spawnWorker = vi.fn() + const controller = new AbortController() + controller.abort() + await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined })) + .rejects.toThrow('native directory picker aborted') + expect(spawnWorker).not.toHaveBeenCalled() + }) + + it('services an abort by closing the dialog thread windows until the worker reports', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + // Attach the expectation BEFORE driving the race: on a fast host the + // close budget can exhaust (and reject) between waitFor ticks, and a + // rejection with no listener yet would count as unhandled. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') + worker.post({ kind: 'showing', threadId: 99 }) + controller.abort() + await vi.waitFor(() => { + expect(close).toHaveBeenCalledWith(99) + }) + worker.post({ kind: 'done', path: null }) + await picked + }) + + it('starts the close service on the showing notice when the abort came first', async () => { + const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) + const { worker, internals } = harness({ closeThreadWindows: closeFailures }) + const controller = new AbortController() + // Attached before the race for the same unhandled-rejection reason above. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') + controller.abort() + expect(closeFailures).not.toHaveBeenCalled() + worker.post({ kind: 'showing', threadId: 12 }) + await vi.waitFor(() => { + expect(closeFailures.mock.calls.length).toBeGreaterThan(1) + }) + worker.post({ kind: 'done', path: null }) + await picked + }) + + it('kills a worker that never reports showing after an abort', async () => { + // The budget runs without a thread id (nothing to WM_CLOSE yet), so a + // worker hung before `showing` cannot dangle the pick. + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed') + controller.abort() + await picked + expect(worker.kill).toHaveBeenCalledOnce() + expect(close).not.toHaveBeenCalled() + }) + + it('kills an unresponsive worker after the close budget', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = pickWin32Directory(controller.signal, internals) + worker.post({ kind: 'showing', threadId: 5 }) + controller.abort() + await expect(picked).rejects.toThrow('dialog unresponsive; worker killed') + expect(worker.kill).toHaveBeenCalledOnce() + expect(close.mock.calls.length).toBeGreaterThan(10) + }) + + // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped + // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. + it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { + await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') + }, 30_000) + + // win32 hosts run the true COM smoke instead: a real dialog opens briefly + // and the abort service closes it (the same lever a disconnecting client pulls). + it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + const controller = new AbortController() + setTimeout(() => { + controller.abort() + }, 400) + await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 4f280f8112..529fb7b2ac 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,3 +1,20 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) +// The Win32 dialog worker builds as its own CJS entry (mirroring +// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining +// the dialog logic while koffi stays an external native require. +export default [ + ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), + { + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'] as ['cjs'], + platform: 'node' as const, + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +] diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 8bb3c0afec..a37ed552a4 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md README.md: 3749b238b56578ec68610bc13550760aa084bad6 -README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be +README.zh.md: 4f7c20e25a4e64f63c611fc56eeec3f249ad67e3 diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 488da5129e..4f7c20e25a 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,18 +2,18 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 -浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 -#### KV 缓存影响 +#### KV Cache 影响 无;该包既不组装也不发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 +- **契约未定义多根目录词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 17d68c141b..c37b67a4d7 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index a79958e9d2..8b53e55af5 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: c3c7b222683bc7731a6c21f2fffd325225099bab -README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db +README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4 +README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index c3c7b22268..196f350d87 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. ## Model Experience -None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request. +None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request. #### KV Cache effect diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 99c0560eb7..0ae0470eab 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 -该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 ## 模型体验 -无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。 +无。该包只是浏览器与其他插件所注册 HTTP/upgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。 #### KV 缓存影响 diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 0dab038f41..4db9433935 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-host-webserver", - "description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts", + "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", "version": "0.0.1", "private": true, "type": "module", @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 37298cb178..6b46b8704d 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,10 +1,9 @@ /** - * @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a - * node:http server plus the `httpServer` service (named-route registry + index - * transform taps + static dist fallback). Knows no harness concepts — every - * feature surface (API bridge, plugin bundles, SSE) is a route some other - * plugin registers. Web (browser) shape only — Electron loads dist over - * file:// and carries fetch over an IPC bridge, not this server. This package + * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http + * server plus the `httpServer` service (HTTP and upgrade route registries, + * index transform taps, and static dist fallback). Knows no harness concepts; + * feature plugins own every registered protocol. Web shape only — Electron + * loads dist over file:// and carries fetch over an IPC bridge. This package * never prints: the URL line belongs to the shell. */ @@ -12,6 +11,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' +import type { Duplex } from 'node:stream' import { dirname } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' @@ -35,6 +35,14 @@ export interface WebRoute { handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> } +/** One exact-path HTTP upgrade registration. */ +export interface WebUpgradeRoute { + /** Absolute pathname, no trailing slash. */ + path: string + /** Owns protocol negotiation and the upgraded socket after dispatch. */ + handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void> +} + /** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ @@ -61,6 +69,8 @@ export class HttpServerService extends Service { private readonly exact = new Map<string, WebRoute>() private readonly prefixes = new Map<string, WebRoute>() + private readonly upgrades = new Map<string, WebUpgradeRoute>() + private readonly upgradedSockets = new Set<Duplex>() private readonly indexTaps: ((html: string) => string)[] = [] private readonly distRoot: string private readonly distIndex: string @@ -98,6 +108,20 @@ export class HttpServerService extends Service { return () => { table.delete(route.path) } } + /** + * Register an exact-path HTTP upgrade route. Duplicate paths throw because + * one socket can have only one protocol owner. + * @param route - pathname and handler owning negotiation plus socket use. + * @returns the disposer removing the route. + */ + registerUpgrade(route: WebUpgradeRoute): () => void { + if (this.upgrades.has(route.path)) { + throw new Error(`webserver: duplicate upgrade route "${route.path}"`) + } + this.upgrades.set(route.path, route) + return () => { this.upgrades.delete(route.path) } + } + /** * Register an index.html transform, applied to every index response in * registration order. @@ -147,6 +171,40 @@ export class HttpServerService extends Service { res.end() }) }) + this.server.on('upgrade', (req, socket, head) => { + const onError = (error: Error): void => { + this.ctx.logger.warn(error) + socket.destroy() + } + socket.on('error', onError) + socket.once('close', () => { + socket.off('error', onError) + this.upgradedSockets.delete(socket) + }) + let route: WebUpgradeRoute | undefined + try { + /* v8 ignore next -- node:http always sets url on server requests. */ + route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname) + } catch (error) { + this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) + socket.destroy() + return + } + if (route === undefined) { + socket.destroy() + return + } + this.upgradedSockets.add(socket) + try { + Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => { + this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) + socket.destroy() + }) + } catch (error) { + this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) + socket.destroy() + } + }) await new Promise<void>((resolve, reject) => { this.server.once('error', reject) @@ -158,12 +216,19 @@ export class HttpServerService extends Service { }) }) - // close + closeAllConnections: held-open responses (SSE) never end on - // their own; without the force-close, close() would hang teardown. - this.ctx.effect(() => () => new Promise<void>((resolve) => { - this.server.close(() => { resolve() }) + // Node does not include upgraded sockets in closeAllConnections(), so the + // service tracks and destroys them as part of the same ownership boundary. + this.ctx.effect(() => async () => { + const serverClosed = new Promise<void>((resolve) => { + this.server.close(() => { resolve() }) + }) this.server.closeAllConnections() - }), 'httpServer.listen') + const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => { + socket.once('close', () => { resolve() }) + socket.destroy() + })) + await Promise.all([serverClosed, ...upgradedClosed]) + }, 'httpServer.listen') } /** Longest-prefix-wins over the prefix table after an exact-table miss. */ diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index b5c8492566..7becf3543b 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant' export const inject = ['invariants'] /** - * Owned relation: route registrations and their disposers must stay + * Owned relation: HTTP and upgrade route registrations and their disposers must stay * symmetric — after the owning fiber of a registered route unloads, the * route table must no longer answer for its path (a stale route would keep * serving a disposed plugin's handler). Checked on every fiber teardown @@ -26,7 +26,10 @@ export const inject = ['invariants'] const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/plugin', () => { const server = ctx.get('httpServer') as - | { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void } + | { + register(route: { kind: 'exact'; path: string; handler: () => void }): () => void + registerUpgrade(route: { path: string; handler: () => void }): () => void + } | undefined if (server === undefined) return // no webserver row in this composition // Register/dispose probe on a reserved path: if dispose leaves the route @@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => { try { server.register(probe)() server.register(probe)() + const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} } + server.registerUpgrade(upgradeProbe)() + server.registerUpgrade(upgradeProbe)() } catch { - fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged') + fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged') } }, { global: true }) } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index c64208eb8e..19a252d53a 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -7,6 +7,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdir } from 'node:fs/promises' +import { once } from 'node:events' +import { connect } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -72,6 +74,24 @@ async function request(port: number, path: string, init?: RequestInit): Promise< return { status: response.status, body: (await response.text()).slice(0, 80) } } +/** Open one raw upgrade request and return after the handler writes its response. */ +async function upgrade(port: number, path: string): Promise<ReturnType<typeof connect>> { + const socket = connect(port, '127.0.0.1') + await once(socket, 'connect') + const response = once(socket, 'data') + socket.write([ + `GET ${path} HTTP/1.1`, + `Host: 127.0.0.1:${String(port)}`, + 'Connection: Upgrade', + 'Upgrade: dsh-test', + '', + '', + ].join('\r\n')) + const [data] = await response as [Buffer] + expect(String(data)).toContain('101 Switching Protocols') + return socket +} + describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough @@ -131,8 +151,51 @@ describe('real Loader composition', () => { expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow() - // Teardown: fiber dispose closes the socket and severs held connections. + // Upgrade routes match exact pathnames, reject duplicate ownership, and + // become registrable again after disposal. The accepted socket stays open + // so the teardown assertion also covers upgraded-connection ownership. + let upgradedServerClosed = false + const disposeUpgrade = server.registerUpgrade({ + path: '/events', + handler: (_req, socket) => { + socket.once('close', () => { upgradedServerClosed = true }) + socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n') + }, + }) + expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })) + .toThrow(/duplicate upgrade route/) + const upgraded = await upgrade(port, '/events?stream=mux') + disposeUpgrade() + expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow() + + // The webserver contains raw-socket errors even before an upgrade handler + // has installed its protocol implementation. + server.registerUpgrade({ + path: '/upgrade-error', + handler: async (_req, socket) => { + await Promise.resolve() + socket.destroy(new Error('test upgrade transport failure')) + }, + }) + const failedUpgrade = connect(port, '127.0.0.1') + failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ }) + await once(failedUpgrade, 'connect') + const failedUpgradeClosed = once(failedUpgrade, 'close') + failedUpgrade.write([ + 'GET /upgrade-error HTTP/1.1', + `Host: 127.0.0.1:${String(port)}`, + 'Connection: Upgrade', + 'Upgrade: dsh-test', + '', + '', + ].join('\r\n')) + await failedUpgradeClosed + expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' }) + + // Teardown closes both ordinary and upgraded sockets before it resolves. await loaded.fiber.dispose() + expect(upgradedServerClosed).toBe(true) + upgraded.destroy() await expect(request(port, '/probe')).rejects.toThrow() }) diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 0dfa7fc0e6..3c4ae19900 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md -README.md: 66b7beabd73cc3fec7230f209a9da0da48a37c95 -README.zh.md: 3f38c94fb4a43bed007d06b8c8558b5bd330a41c +README.md: 92d9fbfa2b8c8db4700562009db49229b2189ab3 +README.zh.md: 5c6e7aad1db6511bdb660b86e257652128db131f diff --git a/packages/llm/README.md b/packages/llm/README.md index 66b7beabd7..92d9fbfa2b 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -6,10 +6,10 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | Package | Role | ctx key | |---|---|---| -| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | -| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | -| `llm-retry/` | Exact-provider normal or unbounded request retry policy | (listens to `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) | -| `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | +| [`llm/`](llm/README.md) | LLM service and shared streaming vocabulary | `ctx.llm` | +| [`token-meter/`](token-meter/README.md) | Replay-aware token measurement | `ctx.tokenMeter` | +| [`llm-retry/`](llm-retry/README.md) | Provider-scoped retry policy | listens to `agent/request-error` | +| [`llm-deepseek/`](llm-deepseek/README.md) | Direct DeepSeek adapter | registers on `ctx.llm` | +| [`llm-pi-ai/`](llm-pi-ai/README.md) | Multi-provider pi-ai adapter | registers on `ctx.llm` | -The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter supplies retry policy and resolves available exact-model identity, context capacity, and reasoning metadata; the retry executor and token meter remain provider-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. +Adapters register provider routes on the seam; retry and token measurement remain separate consumers. The child READMEs own routing, metadata, replay, and provider-wire details; the [LLM architecture decisions](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) own the rationale. diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 3f38c94fb4..5c6e7aad1d 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -1,15 +1,15 @@ -# llm/:LLM(大语言模型)能力家族 +# llm/ — LLM 能力家族 [English](README.md) | 中文 -LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内容块词汇和流分片组装器;适配器是在 `ctx.llm` 上注册的具体实现。这些全是**产品**包(package)。 +LLM(大语言模型)seam 及其提供方适配器。接口包(`llm`)负责抽象服务、内容块词汇和流式分片组装器;适配器是注册到 `ctx.llm` 的具体实现。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| -| `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` | -| `token-meter/` | 感知回放的请求 token 与表层 token 测量 | `ctx.tokenMeter` | -| `llm-retry/` | 确切提供方的常规或无界请求重试策略 | (监听 `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API 适配器,直接使用 fetch + eventsource-parser 和 SSE(Server-Sent Events) | (注册到 `ctx.llm`) | -| `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) | +| [`llm/`](llm/README.md) | LLM 服务和共享流式词汇 | `ctx.llm` | +| [`token-meter/`](token-meter/README.md) | 可感知回放的 token 测量 | `ctx.tokenMeter` | +| [`llm-retry/`](llm-retry/README.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` | +| [`llm-deepseek/`](llm-deepseek/README.md) | 直接 DeepSeek 适配器 | 注册到 `ctx.llm` | +| [`llm-pi-ai/`](llm-pi-ai/README.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` | -接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器以扁平结构并列在该分组下。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。负责该路由的适配器提供重试策略,并解析可用的确切模型身份、上下文容量和推理元数据;重试执行器与 token 计量器仍与提供方无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩(compaction)策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 +适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)负责设计原理。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..3eb54a7a9f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: 0cd265cadb2b2a619613761062ab2cef209bec83 +README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..0cd265cadb 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -40,7 +40,7 @@ The plugin registers the single provider route `deepseek-official` together with `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. -`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. @@ -63,7 +63,7 @@ The plugin also declares its route in the configurable-provider directory (`ctx. Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. -## Wire-format notes (verified live + against the official docs) +## Wire-format notes - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. @@ -75,10 +75,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). -## Testing - -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. - ## Model Experience ### DeepSeek request diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..1883b05427 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -4,7 +4,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(Server-Sent Events,由 `eventsource-parser` 分帧),将官方协议格式(wire format;真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 -同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包(package)拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。 +同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。 包根入口导出 Cordis 插件契约与 `DeepSeekAdapter`;协议序列化、SSE 解析与分片转换 helper 不属于该根契约。 @@ -40,7 +40,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 @@ -63,7 +63,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 -## 协议格式说明(已通过实时请求与官方文档验证) +## 协议格式说明 - 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。 @@ -75,10 +75,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_<status>`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 -## 测试 - -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 - ## 模型体验 ### DeepSeek 请求 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 2c39e2d920..9f9f305b83 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..c0d74d3f75 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -35,6 +35,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } /** @@ -181,7 +183,7 @@ export class DeepSeekAdapter extends LlmAdapter { ? { provider, id: model, name: model } : modelInfo(provider, configured), context: { contextWindow }, - defaultMaxTokens: connection.maxTokens, + defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, ...connection.defaults.thinking === 'disabled' ? { reasoning: { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3ecc0bec77..cd2bb9a24e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -68,7 +68,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -85,6 +85,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({ name: z.string(), description: z.string(), contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), }) export const Config: z<Config> = z.object({ @@ -125,6 +126,12 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, ) } + if (model.maxTokens !== undefined + && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -132,6 +139,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..9d104ace08 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, - errorChain, - LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, @@ -206,18 +204,22 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) - it('rejects a per-request effort before I/O when thinking is disabled', async () => { + it('reports a per-request effort failure before I/O when thinking is disabled', async () => { const server = await mockServer([]) const ctx = await harness(server.url, { thinking: 'disabled' }) - await expect(assemble(ctx, { + const result = await assemble(ctx, { model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('high'), messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'plugin', plugin: 'test' }, })], - })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) expect(server.requests).toHaveLength(0) }) @@ -250,23 +252,22 @@ describe('DeepSeekAdapter against a mock server', () => { [400, 'INVALID_REQUEST'], [500, 'SERVER'], [503, 'SERVER'], - ])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => { + ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { const behavior: Behavior = { kind: 'http-error', status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior]) + const server = await mockServer([behavior]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(`failed with ${status}`) - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).code), - ).resolves.toBe(code) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: `failed with ${status}`, code, status }, + }) }) - it('classifies a thrown HTTP context-window rejection with the canonical code', async () => { + it('classifies an HTTP context-window failure with the canonical code', async () => { const server = await mockServer([{ kind: 'http-error', status: 400, @@ -279,9 +280,11 @@ describe('DeepSeekAdapter against a mock server', () => { }), }]) const ctx = await harness(server.url) - const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).code) - expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, + }) }) it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { @@ -292,19 +295,16 @@ describe('DeepSeekAdapter against a mock server', () => { headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, }]) const ctx = await harness(server.url) - let thrown: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(LlmError) - expect((thrown as LlmError).failure).toEqual({ - message: 'slow down', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 2_000, - requestId: ProviderRequestId('req-429'), + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }, }) }) @@ -322,16 +322,17 @@ describe('DeepSeekAdapter against a mock server', () => { }, }]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ - failure: { - message: 'come back later', - code: 'SERVER', - status: 503, - providerRetryAfterMs: 3_000, - requestId: ProviderRequestId('deepseek-503'), - }, - }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) } finally { dateNow.mockRestore() } @@ -352,13 +353,11 @@ describe('DeepSeekAdapter against a mock server', () => { headers: { 'retry-after': value }, }]) const ctx = await harness(server.url) - let thrown: LlmError | undefined - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - if (error instanceof LlmError) thrown = error - } - expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, + }) } }) @@ -379,53 +378,50 @@ describe('DeepSeekAdapter against a mock server', () => { it('keeps the status-line message for JSON error bodies without a message', async () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/HTTP 500/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toMatch(/HTTP 500/) }) it('keeps the status-line message for non-JSON error bodies', async () => { const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/HTTP 502/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toMatch(/HTTP 502/) }) it('maps unusual statuses to HTTP_<status>', () => { expect(httpErrorCode(418)).toBe('HTTP_418') }) - it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => { - // Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed` - // whose actionable detail (ECONNREFUSED) lives on `cause`. + it('reports a transport failure with the endpoint in the message', async () => { + // Port 1 is reserved/unbound, so the service normalizes the fetch failure. const ctx = await harness('http://127.0.0.1:1') - let caught: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - caught = error - } - expect(caught).toBeInstanceOf(LlmError) - const llmError = caught as LlmError - expect(llmError.code).toBe('TRANSPORT') - expect(llmError.message).toContain('http://127.0.0.1:1') - expect(llmError.cause).toBeInstanceOf(TypeError) - // The chain renderer reaches the transport diagnosis through the cause. - expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { + code: 'TRANSPORT', + message: 'DeepSeek API request to http://127.0.0.1:1 failed', + }, + }) }) - it('classifies an aborted request without losing the transport rejection', async () => { + it('classifies an aborted request as an aborted finish', async () => { const controller = new AbortController() controller.abort() const ctx = await harness('http://127.0.0.1:1') - let caught: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal }) - } catch (error: unknown) { - caught = error - } - expect(caught).toBeInstanceOf(LlmError) - expect(caught).toMatchObject({ code: 'ABORTED' }) - expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' }) + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + }) + expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) }) it('throws EMPTY_RESPONSE when the response has no body', async () => { @@ -443,20 +439,17 @@ describe('DeepSeekAdapter against a mock server', () => { } }) - it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => { + it('classifies an abrupt body close as TRANSPORT', async () => { const server = await mockServer([{ kind: 'close-early', events: ['{"choices":[{"delta":{"content":"par"}}]}'], }]) const ctx = await harness(server.url) - let caught: unknown - try { - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - caught = error - } - expect(caught).toMatchObject({ code: 'TRANSPORT' }) - expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('TRANSPORT') + expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/) }) it('aborts mid-stream via the request signal', async () => { @@ -478,7 +471,13 @@ describe('DeepSeekAdapter against a mock server', () => { })() setTimeout(() => { controller.abort() }, 30) - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + const chunks = await pending + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('finish') + if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') + expect(chunks[0].reason.kind).toBe('aborted') + if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') + expect(chunks[0].reason.failure.code).toBe('ABORTED') }) it('maps connection failures to TRANSPORT without losing the cause', async () => { @@ -793,6 +792,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + it('rejects invalid context capacity when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -858,12 +877,15 @@ describe('plugin registration and config', () => { // only the request itself needs a key. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the credential store — the path that keeps the // secret out of configuration files — and mentions a literal key last. - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) + const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + expect(second.finish.failure.message) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { @@ -883,8 +905,8 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) }) it('prefers explicit config over env for key and base URL', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 25cf4f293b..11df9e1d81 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -96,7 +96,8 @@ describe('request-level dynamic configuration', () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: server.url }) - await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const keyless = await prompt(ctx) + expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) await ctx.credentials.set(KEY_REF, 'sk-arrived') await prompt(ctx) expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 2a1a47b253..7e616b5cd1 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: e8c2682cbb72ca1ac6a5ad6b26bdf63f0695716b -README.zh.md: 5fb19ee1343e905352609d96e7f540c1a411b4d8 +README.md: 75b2136315aed758f18f7fe82afcd4903f4a7b98 +README.zh.md: ea67250549f1d23d48455fd185283b00183dd538 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e8c2682cbb..75b2136315 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -75,10 +75,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH pi-ai installs several provider SDKs and lazy-loads the one selected by the catalog model. The dependency weight is isolated to this opt-in adapter package. -## Testing - -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`. - ## Model Experience ### Provider request through pi-ai diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 5fb19ee134..ea67250549 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -4,7 +4,7 @@ 基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 -包(package)根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 +包根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 ## 配置 @@ -75,10 +75,6 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK。该可选适配器包将依赖体量隔离在自身范围内。 -## 测试 - -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 - ## 模型体验 ### 通过 pi-ai 发起的提供方请求 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 43b97a14f0..cae6c07b7c 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..6f6e6c7ba6 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -85,7 +85,7 @@ describe('PiAiAdapter provider routing', () => { }) }) - it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => { + it('uses a dynamic request effort and reports unsupported efforts before network I/O', async () => { const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await harness(server.url, { reasoning: 'max' }) @@ -104,11 +104,15 @@ describe('PiAiAdapter provider routing', () => { expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) expect(server.requests[1]).not.toHaveProperty('reasoning_effort') - await expect(assemble(ctx, { + const unsupported = await assemble(ctx, { model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('xhigh'), messages: [], - })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + expect(unsupported.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) expect(server.requests).toHaveLength(2) }) @@ -125,19 +129,19 @@ describe('PiAiAdapter provider routing', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) }) - it('rejects stop sequences rather than silently ignoring them', async () => { + it('reports unsupported stop sequences rather than silently ignoring them', async () => { const server = await mockServer([]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] })) - .rejects.toMatchObject({ code: 'UNSUPPORTED_OPTION' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_OPTION' } }) expect(server.requests).toEqual([]) }) - it('rejects unknown catalog models before network I/O', async () => { + it('reports unknown catalog models before network I/O', async () => { const server = await mockServer([]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'not-in-the-catalog', messages: [] })) - .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) + const result = await assemble(ctx, { model: 'not-in-the-catalog', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } }) expect(server.requests).toEqual([]) }) @@ -237,8 +241,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents, delayMs: 200 }]) const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'TIMEOUT' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } }) await Promise.race([ server.responseClosed, new Promise<never>((_resolve, reject) => { @@ -393,10 +397,12 @@ describe('provider profile lifecycle', () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) + const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + expect(second.finish.failure.message).toMatch(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) expect(server.requests).toHaveLength(0) }) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..94c81b8cae 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -105,8 +105,8 @@ describe('request-level dynamic profiles', () => { // composition route stays. await ctx.settings.replace(NS, {}) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } }) }) it('rotates the per-request credential referenced by apiKeyEnv', async () => { diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index cf25cdcd4e..b1157f806f 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md -README.md: 8de3ea8c9321f04f5af1b0d7ab361f73eaabc822 -README.zh.md: 978854e9466e271535a10fcea406d0dcb5607285 +README.md: 23b55a30989cc51d4dd9076b61b6595452b0abd0 +README.zh.md: 267ef12a87561fd8effef726a781e505225baf03 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 8de3ea8c93..23b55a3098 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -48,6 +48,6 @@ The reconstructed request preserves the prior prefix and is eligible for provide - **Agent turns are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. - **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls. -- **Finite plugin budgets add** — normal mode counts only its configured codes and exact provider policy, while context-overflow compaction owns a separate budget. A future overlapping policy must document and test registration-order behavior. +- **Finite plugin budgets add** — normal mode counts only its configured codes and exact provider policy, while context-overflow compaction owns a separate budget. Any overlapping policy must define registration-order behavior. - **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing. - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 978854e946..267ef12a87 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -48,6 +48,6 @@ - **agent 轮次是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法持久地区分各次尝试已经发出的分片。 - **always mode 会重试永久性失败**:身份验证、配额、无效请求、协议和无法恢复的上下文错误都会继续重试,直至成功、取消或 dispose;部署负责提供方特定的成本与延迟控制。 -- **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩(compaction)则拥有独立预算。未来如有重叠策略,必须记录并测试注册顺序行为。 +- **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩(compaction)则拥有独立预算。任何重叠策略都必须定义注册顺序行为。 - **恢复策略按 waterfall 顺序组合**:always mode 会先接受下游重试,再应用自己的回退。后续策略如果忽略取消且永不结算,也会阻止回退、轮次完全停稳和插件 dispose 完成。 - **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。 diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 62dacddcb3..d3b6afebba 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts index a0de5840af..4dd352e8c8 100644 --- a/packages/llm/llm-retry/src/history.ts +++ b/packages/llm/llm-retry/src/history.ts @@ -1,28 +1,29 @@ -/** Durable request-route lookup for one closed model step. @module @deepseek-ai/dsh-llm-retry/history */ +/** Durable request-route lookup for one open model step. @module @deepseek-ai/dsh-llm-retry/history */ import type { SessionEvent } from '@deepseek-ai/dsh-session' /** - * Find the provider in force when one step closed, excluding later recovery mutations. + * Find the provider in force for one currently open step. * Request headers remain effective across turn boundaries until a newer full * snapshot changes them; every provider change requires a newer full snapshot. - * @param events - session events containing the closed step. + * @param events - session events ending inside the open step. * @param turn - turn that owns the failed step. * @param step - failed step whose provider is required. - * @returns the provider from the request header in force at that step boundary. + * @returns the provider from the request header in force for the step. */ -export function providerForClosedStep( +export function providerForOpenStep( events: readonly SessionEvent[], turn: number, step: number, ): string | undefined { - const stepEndIndex = events.findLastIndex(event => - event.type === 'step/end' + const stepStartIndex = events.findLastIndex(event => + event.type === 'step/start' && event.data.turn === turn && event.data.step === step, ) - if (stepEndIndex < 0) return undefined - for (let index = stepEndIndex; index >= 0; index -= 1) { + if (stepStartIndex < 0 || events.slice(stepStartIndex + 1).some(event => + event.type === 'step/end' || event.type === 'turn/end')) return undefined + for (let index = events.length - 1; index >= 0; index -= 1) { // The loop bounds prove this indexed read exists. // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index ec435fbe9e..fd756a39cc 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -1,5 +1,5 @@ /** - * Provider-routed model-request retry policy on the agent loop's closed-step + * Provider-routed model-request retry policy on the agent loop's request * recovery seam. Each scheduled retry is durable before its cancellable wait. * * @module @deepseek-ai/dsh-llm-retry @@ -7,14 +7,13 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { providerForClosedStep } from './history.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ + /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ 'llm/retry': { turn: number step: number @@ -174,24 +173,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna async function recover( agent: Agent, - turn: number, - step: number, - _error: RequestError, - failure: LlmFailure, - priorFailures: readonly LlmFailure[], - policy: ResolvedRetryPolicy | undefined, + context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>, ): Promise<RequestErrorAction> { + const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() - // The call-local policy belongs to the registration that served this - // failure. Recover only the durable provider identity from the header; - // downstream recovery may append later state before an always fallback. - const provider = providerForClosedStep(agent.session.events, turn, step) - /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ - if (provider === undefined) { - throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) - } if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return const fusedSignal = AbortSignal.any([signal, lifetime.signal]) @@ -213,11 +200,10 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const firstPriorTurn = turn - priorFailures.length const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => event.type === 'llm/retry' - && event.data.turn >= firstPriorTurn - && event.data.turn < turn + && event.data.turn === turn + && event.data.step === step && event.data.provider === provider && event.data.policyKey === policyKey, ) @@ -243,12 +229,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const disposeListener = ctx.on('agent/request-error', ( agent: Agent, - turn: number, - step: number, - error: RequestError, - failure: LlmFailure, - priorFailures: readonly LlmFailure[], - policy: ResolvedRetryPolicy | undefined, + context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>, ) => { @@ -256,7 +237,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined) - return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next)) + return track(recover(agent, context, signal, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 03379c82d0..d324c012f2 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -5,7 +5,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { LlmFailure } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { providerForClosedStep } from './history.ts' +import { providerForOpenStep } from './history.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -41,35 +41,7 @@ function validateFailure(value: unknown, fail: InvariantFailure): asserts value } } -/** Find the first turn in the structured-failure retry chain containing `turn`. */ -function retryChainStart(history: readonly SessionEvent[], turn: number): number { - let startIndex = history.findLastIndex( - event => event.type === 'turn/start' && event.data.turn === turn, - ) - while (startIndex >= 0) { - const start = history[startIndex] - if (start?.type !== 'turn/start' || start.data.trigger.kind !== 'retry') break - - let endIndex = startIndex - 1 - while (endIndex >= 0 && history[endIndex]?.type !== 'turn/end') endIndex -= 1 - const end = history[endIndex] - if (end?.type !== 'turn/end' - || end.data.reason.kind !== 'error' - || end.data.reason.failure === undefined) break - - const previousStart = history.findLastIndex( - (event, index) => - index < endIndex - && event.type === 'turn/start' - && event.data.turn === end.data.turn, - ) - if (previousStart < 0) break - startIndex = previousStart - } - return startIndex -} - -/** Validate one retry record against the open turn and most recently closed step. */ +/** Validate one retry record against the currently open request step. */ function validateRetry( history: readonly SessionEvent[], event: SessionEvent<'llm/retry'>, @@ -106,49 +78,34 @@ function validateRetry( fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) } - const currentTurnEvents: SessionEvent[] = [] - let openTurn: number | undefined - for (const prior of history.slice().reverse()) { - if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') - if (prior.type === 'turn/start') { - openTurn = prior.data.turn - break - } - currentTurnEvents.push(prior) + const turnBoundary = history.findLast(prior => + prior.type === 'turn/start' || prior.type === 'turn/end') + if (turnBoundary?.type !== 'turn/start') { + fail('llm/retry must be appended inside an open turn') } - if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') - if (turn !== openTurn) { - fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) + if (turn !== turnBoundary.data.turn) { + fail(`llm/retry names turn ${turn}, but the open turn is ${turnBoundary.data.turn}`) } - let closedStep: number | undefined - for (const prior of currentTurnEvents) { - if (prior.type === 'step/start') { - fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) - } - if (prior.type === 'step/end') { - closedStep = prior.data.step - break - } + const stepBoundary = history.findLast(prior => + prior.type === 'step/start' || prior.type === 'step/end') + if (stepBoundary?.type !== 'step/start') { + fail('llm/retry must be appended inside an open step') } - if (closedStep === undefined || step !== closedStep) { - fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) + if (step !== stepBoundary.data.step || turn !== stepBoundary.data.turn) { + fail(`llm/retry names turn ${turn}/step ${step}, but the open step is ${stepBoundary.data.turn}/${stepBoundary.data.step}`) } - const routedProvider = providerForClosedStep(history, turn, step) + const routedProvider = providerForOpenStep(history, turn, step) if (routedProvider !== provider) { fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`) } - const chainStart = retryChainStart(history, turn) - const chain = history.slice(Math.max(chainStart, 0)) - const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message') - const chainRetries = chain.slice(lastSuccess + 1) - .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') - if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) { - fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) - } - const priorPolicyRetry = chainRetries.findLast(prior => - prior.data.provider === provider && prior.data.policyKey === policyKey) + const priorPolicyRetry = history.findLast((prior): prior is SessionEvent<'llm/retry'> => + prior.type === 'llm/retry' + && prior.data.turn === turn + && prior.data.step === step + && prior.data.provider === provider + && prior.data.policyKey === policyKey) const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1 if (retry !== expectedRetry) { fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index fd35af7e24..1c478b226b 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' -import { createUserMessage, ProviderRequestId , createMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' -import { providerForClosedStep } from '../src/history.ts' +import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise<Context> { const ctx = new Context() @@ -15,26 +15,24 @@ async function setup(): Promise<Context> { return ctx } -function closeStep(ctx: Context, id: string, turn = 1, step = 1) { +function openStep(ctx: Context, id: string, turn = 1, step = 1) { const session = ctx.sessions.create(SessionId(id)) - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('step/start', { turn, step }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial', }) - session.append('step/end', { turn, step }) return session } function appendRetryTurn(session: Session, turn: number) { - session.append('turn/start', { turn, trigger: { kind: 'retry' } }) + session.append('turn/start', { turn }) session.append('step/start', { turn, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial', }) - session.append('step/end', { turn, step: 1 }) session.append('llm/retry', { turn, step: 1, ...normal }) } @@ -58,28 +56,24 @@ const always = { } describe('llm-retry invariants', () => { - it('has no provider without the requested closed step or a route marker', () => { - expect(providerForClosedStep([], 1, 1)).toBeUndefined() - expect(providerForClosedStep([{ - type: 'step/end', + it('has no provider without the requested open step or a route marker', () => { + expect(providerForOpenStep([], 1, 1)).toBeUndefined() + expect(providerForOpenStep([{ + type: 'step/start', data: { turn: 1, step: 1 }, }] as never, 1, 1)).toBeUndefined() }) - it('accepts bounded and unbounded records after successive closed steps', async () => { + it('accepts successive bounded and unbounded records inside their open steps', async () => { const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-valid') + const session = openStep(ctx, 'retry-invariant-valid') expect(() => { session.append('llm/retry', { turn: 1, step: 1, ...normal }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) session.append('llm/retry', { - turn: 2, step: 1, ...normal, retry: 2, delayMs: 0, + turn: 1, step: 1, ...normal, retry: 2, delayMs: 0, }) - const unbounded = closeStep(ctx, 'retry-invariant-always') + const unbounded = openStep(ctx, 'retry-invariant-always') unbounded.append('llm/retry', { turn: 1, step: 1, ...always }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() @@ -87,7 +81,7 @@ describe('llm-retry invariants', () => { it('validates the complete durable failure payload', async () => { const ctx = await setup() - const complete = closeStep(ctx, 'retry-invariant-complete-failure') + const complete = openStep(ctx, 'retry-invariant-complete-failure') expect(() => { complete.append('llm/retry', { turn: 1, @@ -126,7 +120,7 @@ describe('llm-retry invariants', () => { ['request-id-empty', { message: 'failed', code: 'RATE_LIMIT', requestId: '' }, /failure\.requestId/], ] for (const [name, invalidFailure, message] of invalidFailures) { - const session = closeStep(ctx, `retry-invariant-failure-${name}`) + const session = openStep(ctx, `retry-invariant-failure-${name}`) expect(() => { session.append('llm/retry', { turn: 1, step: 1, ...always, failure: invalidFailure, @@ -150,95 +144,75 @@ describe('llm-retry invariants', () => { ['delay-type', { ...normal, delayMs: '1' }, /delayMs/], ])('rejects invalid retry data: %s', async (name, data, message) => { const ctx = await setup() - const session = closeStep(ctx, `retry-invariant-${name}`) + const session = openStep(ctx, `retry-invariant-${name}`) expect(() => { session.append('llm/retry', { turn: 1, step: 1, ...data } as never) }).toThrow(message) }) - it('rejects records outside the latest closed step of an open turn', async () => { + it('rejects records outside the currently open turn and step', async () => { const ctx = await setup() const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) expect(() => { absent.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/inside an open turn/) - const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') + const wrongTurn = openStep(ctx, 'retry-invariant-wrong-turn') expect(() => { wrongTurn.append('llm/retry', { turn: 2, step: 1, ...normal }) }).toThrow(/open turn is 1/) - const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) - openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - openStep.append('step/start', { turn: 1, step: 1 }) + const closedStep = openStep(ctx, 'retry-invariant-closed-step') + closedStep.append('step/end', { turn: 1, step: 1 }) expect(() => { - openStep.append('llm/retry', { turn: 1, step: 1, ...normal }) - }).toThrow(/step 1 is still open/) + closedStep.append('llm/retry', { turn: 1, step: 1, ...normal }) + }).toThrow(/inside an open step/) const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) - noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + noStep.append('turn/start', { turn: 1 }) expect(() => { noStep.append('llm/retry', { turn: 1, step: 1, ...normal }) - }).toThrow(/latest closed step is undefined/) + }).toThrow(/inside an open step/) - const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') + const wrongStep = openStep(ctx, 'retry-invariant-wrong-step') expect(() => { wrongStep.append('llm/retry', { turn: 1, step: 2, ...normal }) - }).toThrow(/latest closed step is 1/) + }).toThrow(/open step is 1\/1/) - const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') - closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + const closedTurn = openStep(ctx, 'retry-invariant-closed-turn') + closedTurn.append('step/end', { turn: 1, step: 1 }) + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } }, + }) expect(() => { closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/inside an open turn/) }) - it('rejects a second retry record for the same step', async () => { + it('accepts successive retries in one step and rejects skipped numbering', async () => { const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-duplicate') + const session = openStep(ctx, 'retry-invariant-number-sequence') session.append('llm/retry', { turn: 1, step: 1, ...normal }) + session.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 2 }) expect(() => { - session.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 2 }) - }).toThrow(/duplicates the retry record/) + session.append('llm/retry', { turn: 1, step: 1, ...always, retry: 2 }) + }).toThrow(/must equal provider policy retry 1/) }) - it('binds retry numbering to the provider policy and resets it after success', async () => { + it('binds retry numbering to the provider policy and resets it for a new step', async () => { const ctx = await setup() - const mismatch = closeStep(ctx, 'retry-invariant-numbering') + const mismatch = openStep(ctx, 'retry-invariant-numbering') mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) - mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - mismatch.append('step/start', { turn: 2, step: 1 }) - mismatch.append('step/end', { turn: 2, step: 1 }) expect(() => { - mismatch.append('llm/retry', { turn: 2, step: 1, ...normal, retry: 1 }) + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 1 }) }).toThrow(/must equal provider policy retry 2/) - const reset = closeStep(ctx, 'retry-invariant-reset') + const reset = openStep(ctx, 'retry-invariant-reset') reset.append('llm/retry', { turn: 1, step: 1, ...normal }) - reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - reset.append('step/start', { turn: 2, step: 1 }) - reset.append('assistant/message', { - turn: 2, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'success' }], - source: { - kind: 'model', - ...{ provider: 'mock', model: 'mock' }, - }, - }), - }, { surfaceOp: 'append' }) - reset.append('step/end', { turn: 2, step: 1 }) - reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) - reset.append('step/start', { turn: 3, step: 1 }) - reset.append('step/end', { turn: 3, step: 1 }) + reset.append('step/end', { turn: 1, step: 1 }) + reset.append('step/start', { turn: 1, step: 2 }) expect(() => { - reset.append('llm/retry', { turn: 3, step: 1, ...normal }) + reset.append('llm/retry', { turn: 1, step: 2, ...normal }) }).not.toThrow() }) @@ -262,9 +236,7 @@ describe('llm-retry invariants', () => { appendRetryTurn(nonFailureEnd, 2) const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start')) - missingStart.append('turn/end', { - turn: 1, - reason: { kind: 'error', step: 1, failure }, + missingStart.append('turn/end', { turn: 1, reason: { kind: 'error', error: failure }, }) appendRetryTurn(missingStart, 2) @@ -274,7 +246,7 @@ describe('llm-retry invariants', () => { it('rejects a provider that does not match the failed request route', async () => { const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-provider') + const session = openStep(ctx, 'retry-invariant-provider') expect(() => { session.append('llm/retry', { turn: 1, step: 1, ...always, provider: 'other' }) }).toThrow(/does not match the failed request provider mock/) @@ -284,7 +256,7 @@ describe('llm-retry invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('retry-invariant-late')) - session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 1 }) session.append('llm/retry', { turn: 1, step: 1, ...normal }) await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 2b5d0234bd..36a1fb5dbe 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -32,13 +32,12 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) const ctx = await backend(kind) try { const session = ctx.sessions.create(SessionId(`retry-${kind}`)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial', }) - session.append('step/end', { turn: 1, step: 1 }) const event = session.append('llm/retry', { turn: 1, step: 1, @@ -49,13 +48,9 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, }) - session.append('turn/end', { - turn: 1, - reason: { - kind: 'error', - step: 1, - failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, - }, + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, + }, }) expect(session.deriveMessages()).toEqual([]) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 50545d8f21..d1500fa781 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -149,15 +149,8 @@ function alwaysConfig(backoff: BackoffConfig = {}): AlwaysRetryPolicyConfig { } } -function waitForIdle(ctx: Context, agent: Agent): Promise<void> { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) +function waitForIdle(_ctx: Context, agent: Agent): Promise<void> { + return agent.whenIdle() } function waitForRetry(ctx: Context, agent: Agent, retryNumber: number): Promise<Extract<SessionEvent, { type: 'llm/retry' }>> { @@ -180,7 +173,7 @@ afterEach(async () => { }) describe('provider-routed retry policy', () => { - it('records the scheduled delay before opening a fresh request attempt', async () => { + it('records the scheduled delay before retrying the request', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('busy', 'RATE_LIMIT', { status: 429 }), @@ -219,7 +212,7 @@ describe('provider-routed retry policy', () => { expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data)) - .toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }]) + .toEqual([{ turn: 1, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toEqual({ id: expect.any(String) as unknown, role: 'assistant', @@ -256,7 +249,7 @@ describe('provider-routed retry policy', () => { expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, - }))).toEqual([{ turn: 2, step: 1 }]) + }))).toEqual([{ turn: 1, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'recovered' }], @@ -289,14 +282,21 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle + const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') const failedChunks = agent.session.events.filter(event => - event.type === 'assistant/chunk' && event.data.turn === 1 && event.data.step === 1, + event.type === 'assistant/chunk' + && retryEvent !== undefined + && event.seq < retryEvent.seq, ) - expect(failedChunks).toHaveLength(6) - expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ + expect(failedChunks).toHaveLength(7) + const assistantMessages = agent.session.events.filter(event => event.type === 'assistant/message') + expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, - }))).toEqual([{ turn: 2, step: 1 }]) + }))).toEqual([{ turn: 1, step: 1 }]) + expect(failedChunks.every(event => + !assistantMessages[0]?.sourceEventSeqs?.includes(event.seq), + )).toBe(true) expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ @@ -337,7 +337,7 @@ describe('provider-routed retry policy', () => { expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', failure: { message: 'busy three', code: 'SERVER' } } }, + data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, }) }) @@ -448,10 +448,14 @@ describe('provider-routed retry policy', () => { expect(adapter.requests).toHaveLength(0) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + const end = agent.session.events.at(-1) + expect(end).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', failure: { code: 'NO_ADAPTER' } } }, + data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } }, }) + if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { + expect(end.data.reason.error.message).toContain('no adapter registered for provider') + } }) it('selects policy by the failed request provider', async () => { @@ -539,9 +543,9 @@ describe('provider-routed retry policy', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1 }, }), }, (ctx) => { - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ...await next(), - provider: turn === 1 ? 'mock' : 'other', + provider: adapter.requests.length === 0 ? 'mock' : 'other', })) })) const agent = context.agentLoop.create(SessionId('retry-provider-budgets'), { @@ -913,9 +917,7 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers<undefined>() let invokeCaptured: (() => Promise<void>) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', ( - _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, - ) => { + ctx.on('agent/request-error', (_agent, _context, _signal, next) => { return new Promise<RequestErrorAction>((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -924,9 +926,7 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async ( - _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, - ) => { + context.on('agent/request-error', async (_agent, _context, _signal, next) => { downstreamCalls += 1 return next() }) @@ -980,9 +980,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async ( - agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, - ) => { + ctx.on('agent/request-error', async (agent, _context, _signal, next) => { agent.cancel({ kind: 'user' }) return next() }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a17074504a..a5e7552496 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -55,14 +55,8 @@ async function harness( return ctx } -function waitForIdle(ctx: Context, agent: Agent): Promise<void> { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject !== agent || status !== 'idle') return - dispose() - resolve() - }) - }) +function waitForIdle(_ctx: Context, agent: Agent): Promise<void> { + return agent.whenIdle() } function sendAndWait(ctx: Context, agent: Agent): Promise<void> { @@ -109,15 +103,15 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server?.requests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) - .toEqual([[1, 1], [2, 1]]) + .toEqual([[1, 1]]) expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('connected after retry') }) it.each([ - ['stream_disconnect', 0] as const, - ['partial_disconnect', 2] as const, + ['stream_disconnect', 1] as const, + ['partial_disconnect', 3] as const, ])('retries %s without committing failed chunks', async (behavior, failedChunkCount) => { const server = await start([behavior, 'success'], { apiKey: 'mock-key', @@ -136,12 +130,15 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) + const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') expect(agent.session.events.filter(event => - event.type === 'assistant/chunk' && event.data.turn === 1, + event.type === 'assistant/chunk' + && retryEvent !== undefined + && event.seq < retryEvent.seq, )).toHaveLength(failedChunkCount) expect(agent.session.events.filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) - .toEqual([[2, 1]]) + .toEqual([[1, 1]]) expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') @@ -166,7 +163,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { .toEqual(['EMPTY_RESPONSE']) expect(agent.session.events.filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) - .toEqual([[2, 1]]) + .toEqual([[1, 1]]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -191,12 +188,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'assistant/chunk' && event.data.turn === 1, - )).toHaveLength(2) + )).toHaveLength(3) expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', failure: { code: 'STREAM_CLOSED' } } }, + data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, }) }) @@ -234,11 +231,15 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - expect(agent.session.events.at(-1)).toMatchObject({ + const end = agent.session.events.at(-1) + expect(end).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', failure: { code: 'TRANSPORT' } } }, + data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, }) + if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { + expect(end.data.reason.error.message).toContain('DeepSeek API request to') + } }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 630a60af19..ef759b6993 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 21f428fb22c9a59a67d86f446ea866c1629b964a -README.zh.md: f6421f0625de7e63432a4863db6fb2d96ecd1b3f +README.md: 5d74ed647f4de3c8ed65554dff736eb8aec9eef9 +README.zh.md: a362ba8b825238325ce70238c5b2f3725f0d8495 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 21f428fb22..5d74ed647f 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -18,10 +18,10 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize adapter-configured call defaults without clamping. -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration as one cancellable, one-shot call. +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration and immutable retry policy as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. @@ -48,7 +48,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. -Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. +Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. ### Call configuration (`call-config.ts`) @@ -71,7 +71,7 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters -Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek-official` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek-official` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish and tool arguments remain raw strings. Adapter implementations may throw or emit a failure finish internally; `LlmService` exposes both as a terminal failure finish. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the adapter rationale and [the terminal-failure decision](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) for the service boundary. ## Model Experience diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index f6421f0625..a362ba8b82 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -提供方无关的 LLM(大语言模型)词汇与抽象服务。本包(package)定义 agent loop(智能体循环)、会话日志和每个插件使用的规范语言。 +提供方无关的 LLM(大语言模型)词汇与抽象服务。本包定义 agent loop(智能体循环)、会话日志和每个插件使用的规范语言。 ## 服务:`LlmService`(ctx key:`llm`) @@ -18,10 +18,10 @@ - `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将其当前适配器注册捕获为一次可取消、一次性调用。 +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将当前适配器注册和不可变重试策略捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。 -`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 +`LlmService` 将最终适配器选择、同步 dispatch、iterator 构造与迭代中的失败规范化为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 @@ -48,7 +48,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。 -流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。 +流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用其 `error` 或 `aborted` 原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。 ### 调用配置(`call-config.ts`) @@ -56,7 +56,7 @@ ### 应用归因(`attribution.ts`) -每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ### 类 @@ -71,7 +71,7 @@ ### 真实适配器 -两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmService` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。 ## 模型体验 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 7adb8e41d9..c0a6994240 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -34,9 +34,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index e888d216a8..d11ee2e52b 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -1,67 +1,40 @@ /** - * Private provider-failure tagging shared by `LlmService` and its consumers. + * Normalization for values thrown by a final LLM adapter boundary. * * @module @deepseek-ai/dsh-llm/adapter-failure */ import { HarnessError } from './error.ts' -import type { LlmFailure, StreamChunk } from './types.ts' -import type { ResolvedRetryPolicy } from './retry-policy.ts' - -/** Call-local facts captured when one model call enters its final adapter boundary. */ -export interface AdapterFailureScope { - /** Errors and normalized facts proven to originate in this call's final adapter boundary. */ - readonly failures: WeakMap<Error, LlmFailure> - /** Immutable policy of the exact adapter registration selected for this call. */ - retryPolicy?: ResolvedRetryPolicy -} - -/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ -const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>() +import type { LlmFailure } from './types.ts' /** - * Bind one call's adapter-failure scope to a unique returned stream handle. - * @param stream - the waterfall-selected stream for this call. - * @param failures - errors tagged by this call's final adapter boundary. - * @returns a unique stream handle that delegates iteration to `stream`. + * Detach serializable provider facts from a value thrown by an adapter. + * @param value - arbitrary value thrown during adapter dispatch or iteration. + * @returns immutable provider-neutral facts suitable for a terminal finish chunk. * @internal */ -export function bindAdapterFailureScope( - stream: AsyncIterable<StreamChunk>, - failures: AdapterFailureScope, -): AsyncIterable<StreamChunk> { - const call = { - [Symbol.asyncIterator](): AsyncIterator<StreamChunk> { - return stream[Symbol.asyncIterator]() - }, - } - adapterFailureScopes.set(call, failures) - return call -} - -/** - * Preserve an adapter's Error identity while tagging its provider origin. - * @param failures - the call-local final-adapter failure scope. - * @param value - arbitrary value thrown by adapter dispatch or iteration. - * @returns the original Error, or a coded Error wrapping a non-Error throw. - * @internal - */ -export function markLlmAdapterFailure( - failures: AdapterFailureScope, - value: unknown, -): Error & { code?: string } { +export function normalizeLlmFailure(value: unknown): LlmFailure { const error = value instanceof Error - ? value as Error & { code?: string } - : new HarnessError(String(value), 'UNKNOWN', { cause: value }) + ? value + : new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value }) // Cross-package copies preserve own data but not class identity. Trust the // carried facts only when both own properties agree after validation. const carried = ownFailureSnapshot(error) - const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({ + if (carried !== undefined && carried.code === ownErrorCode(error)) return carried + return Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), }) - failures.failures.set(error, failure) - return error +} + +/** Render a non-Error throw without letting hostile coercion escape normalization. */ +function thrownMessage(value: unknown): string { + try { + const message = String(value) + return message.length > 0 ? message : 'LLM adapter failed' + } catch (_hostileThrownValue) { + return 'LLM adapter failed' + } } /** Read a foreign error's own data-backed `code` without invoking accessors. */ @@ -129,46 +102,3 @@ function errorMessage(error: Error): string { function harnessErrorCode(error: Error): string { return error instanceof HarnessError ? error.code : 'UNKNOWN' } - -/** - * Whether a failure came from final adapter dispatch, iterator construction, - * or iteration for the call represented by the exact returned stream handle. - * @param stream - the exact stream returned by the model call being classified. - * @param value - arbitrary failure caught by a model-call consumer. - * @returns true only for errors tagged at that call's final adapter boundary. - */ -export function isLlmAdapterFailure( - stream: AsyncIterable<StreamChunk>, - value: unknown, -): value is Error & { code?: string } { - const failures = adapterFailureScopes.get(stream) - return value instanceof Error && failures !== undefined && failures.failures.has(value) -} - -/** - * Retrieve normalized provider facts only for an Error tagged by this exact - * model call's final adapter boundary. - * @param stream - the exact stream returned to the consumer. - * @param value - the caught failure. - * @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures. - */ -export function llmFailureOf( - stream: AsyncIterable<StreamChunk>, - value: unknown, -): LlmFailure | undefined { - const failures = adapterFailureScopes.get(stream) - return value instanceof Error ? failures?.failures.get(value) : undefined -} - -/** - * Read the retry policy of the exact registration selected at this call's - * final adapter boundary. The policy remains available after that registration - * is disposed or replaced; absence means no final adapter served the call. - * @param stream - the exact stream returned by the model call. - * @returns the immutable serving-registration policy, or `undefined`. - */ -export function llmRetryPolicyOf( - stream: AsyncIterable<StreamChunk>, -): ResolvedRetryPolicy | undefined { - return adapterFailureScopes.get(stream)?.retryPolicy -} diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 252d6b89ac..a0e1332417 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -127,11 +127,15 @@ export class BlockAssembler { /** * Assemble all blocks seen so far, in stream order. - * @returns one block per seen index; an open block assembles from its - * accumulated deltas (an unknown block type never closed by `block-end` throws). + * @returns one block per seen index, except that max-token truncation drops + * tool calls that cannot be executed safely; an open block assembles from + * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[] { - return this.order.map(index => this.assemble(this.mustGet(index), index)) + const blocks = this.order.map(index => this.assemble(this.mustGet(index), index)) + return this.finish.kind === 'max-tokens' + ? blocks.filter(block => block.type !== 'tool-call') + : blocks } /** Usage from the `usage` chunk; undefined until one arrives. */ diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index c4eb816ff6..fbb8bccca5 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -93,7 +93,8 @@ export function isQuotaExceededError(detail: string): boolean { /** * Render a thrown value with its full `cause` chain and AggregateError * members, so transport wrappers like undici's `TypeError: fetch failed` - * surface the underlying failure instead of masking it. Diagnostic-surface + * surface the underlying failure instead of masking it. Plain structured + * failures render their own data-backed `message`. Diagnostic-surface * rendering only (messages, notices, logs) — never parse the result; route on * {@link HarnessError.code}. * @param value - the caught value (`unknown` in catch clauses). @@ -109,7 +110,15 @@ export function errorChain(value: unknown): string { if (path.has(current)) return '<circular cause>' path.add(current) try { - if (!(current instanceof Error)) return String(current) + if (!(current instanceof Error)) { + if (typeof current === 'object' && current !== null) { + const descriptor = Object.getOwnPropertyDescriptor(current, 'message') + if (descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string') { + return descriptor.value + } + } + return String(current) + } const message = current.message === '' ? current.name : current.message const members = current instanceof AggregateError && current.errors.length > 0 ? ` [${current.errors.map(render).join('; ')}]` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8b3c839787..59932f676c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -24,8 +24,7 @@ import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' import { HarnessError } from './error.ts' -import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' -import type { AdapterFailureScope } from './adapter-failure.ts' +import { normalizeLlmFailure } from './adapter-failure.ts' export * from './attribution.ts' export * from './brand.ts' @@ -37,7 +36,6 @@ export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -126,6 +124,8 @@ export class LlmError extends HarnessError { export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Immutable retry policy captured with the adapter registration. */ + readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext /** Config fields materialized by the captured adapter rather than proposed by the caller. */ @@ -633,12 +633,19 @@ export class LlmService extends Service { let dispatched = false return Object.freeze({ config: resolvedConfig, + retryPolicy: registration.retryPolicy, adapterDefaults, ...context === undefined ? {} : { context }, stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') } + if (!callConfigEquals(options, resolvedConfig)) { + throw new LlmError( + 'prepared LLM call config changed before adapter dispatch', + 'INVALID_PREPARED_CALL', + ) + } dispatched = true return this.streamWithRegistration(options, { registration, config: resolvedConfig }) }, @@ -668,22 +675,17 @@ export class LlmService extends Service { } /** - * Final adapter boundary. It tags only failures from adapter selection, - * synchronous dispatch, iterator construction, or iteration while preserving - * the original Error object. Middleware outside this generator remains - * distinguishable as plugin work. An iteration failure skips adapter cleanup - * so it cannot suppress the primary provider error. A downstream close awaits - * adapter cleanup, whose failures remain ordinary untagged work. + * Final adapter boundary. Adapter selection, dispatch, iterator construction, + * and iteration failures become one terminal failure chunk. Middleware and + * downstream consumer failures remain thrown plugin or consumer errors. */ private async * adapterStream( options: GenerateOptions, - failures: AdapterFailureScope, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncGenerator<StreamChunk> { let iterator: AsyncIterator<StreamChunk> try { const registration = prepared?.registration ?? this.registration(options.provider) - failures.retryPolicy = registration.retryPolicy const resolvedConfig = prepared === undefined ? (await this.resolveCallFor(registration, options, options.signal)).config : prepared.config @@ -702,32 +704,34 @@ export class LlmService extends Service { const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { - throw markLlmAdapterFailure(failures, error) + yield adapterFailureChunk(error, options.signal) + return } let completed = false - let iterationFailed = false try { while (true) { - let value: StreamChunk + let item: { done: true } | { done: false; value: StreamChunk } try { - const item = await iterator.next() - if (item.done) { - completed = true - return - } - value = item.value + const next = await iterator.next() + item = next.done + ? { done: true } + : { done: false, value: next.value } } catch (error: unknown) { - iterationFailed = true - throw markLlmAdapterFailure(failures, error) + completed = true + yield adapterFailureChunk(error, options.signal) + return + } + if (item.done) { + completed = true + return } // End the adapter-owned try before yielding: consumer/middleware - // failures resumed into this generator must remain untagged. - yield value + // failures resumed into this generator must remain thrown. + yield item.value } } finally { - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. - if (!completed && !iterationFailed) { + if (!completed) { const close = iterator.return?.bind(iterator) if (close) await close() } @@ -735,15 +739,13 @@ export class LlmService extends Service { } /** - * Stream one model call as raw chunks (token-level deltas). Throws - * `LlmError` with code `NO_ADAPTER` if no adapter is registered for - * `options.provider`. Replay state is retained only when the same adapter - * instance owns its historical provider and the target provider. Final - * adapter selection remains fixed through asynchronous exact-model resolution - * and dispatch. Selection, dispatch, and iteration failures retain their - * original Error identity and are tagged in a call-local scope for narrow - * agent-loop request recovery; middleware and nested-call failures remain - * untagged for the outer call. + * Stream one model call as raw chunks (token-level deltas). Replay state is + * retained only when the same adapter instance owns its historical provider + * and the target provider. Final adapter selection remains fixed through + * asynchronous exact-model resolution and dispatch. Adapter selection, + * dispatch, and iteration failures become terminal `error` or `aborted` + * finish chunks; middleware, nested-call, cleanup, and consumer failures + * remain thrown. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ @@ -755,14 +757,23 @@ export class LlmService extends Service { options: GenerateOptions, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncIterable<StreamChunk> { - const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() } - const stream = this.ctx.waterfall( + return this.ctx.waterfall( this, 'llm/stream', options, - () => this.adapterStream(options, failures, prepared), + () => this.adapterStream(options, prepared), ) - return bindAdapterFailureScope(stream, failures) + } +} + +/** Convert one adapter throw into the stream protocol's terminal outcome. */ +function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk { + const failure = normalizeLlmFailure(error) + return { + type: 'finish', + reason: signal?.aborted || failure.code === 'ABORTED' + ? { kind: 'aborted', failure } + : { kind: 'error', failure }, } } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a755d87126..2f1afb5155 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -72,7 +72,9 @@ async function* validateStream( usageSeen = true break case 'finish': - if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`) + if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') { + fail(`LLM stream finished with ${open.size} open block(s)`) + } finished = true break } diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 3fa7606c6c..7db1f855a8 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -29,17 +29,99 @@ export interface ToolMessageSource { callId: CallId } +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +export type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + /** Current state, where a later snapshot from the same producer supersedes an earlier one. */ + | 'snapshot' + /** A one-off account of something that just happened; it supersedes nothing. */ + | 'notice' + /** A message another agent addressed to this one. */ + | 'relay' + /** Material lifted out of another session's log, possibly reduced on the way in. */ + | 'recall' + +/** One named contribution to a `snapshot`-form context, in assembly order. */ +export interface ContextSnapshotSection { + /** The contributing subsystem's name. */ + readonly name: string + /** That contribution's model-facing text, exactly as assembled. */ + readonly text: string +} + +/** + * Producer-declared {@link ContextForm} and the fields that form requires, + * mixed into the source shapes that carry one. + * + * Discriminated by `form` so a producer cannot declare a shape without the + * facts that shape is presented from: a `notice` must record its one-line + * account, a `snapshot` its sections. Omitting `form` stays valid — an + * undeclared context is the documented default. + */ +export type ContextFormed = + | { readonly form?: never } + | { readonly form: 'instructions' } + | { readonly form: 'catalog' } + | { + readonly form: 'snapshot' + /** The named contributions this snapshot assembled, in order. */ + readonly sections: readonly ContextSnapshotSection[] + } + | { + readonly form: 'notice' + /** One-line account of what happened, shown without expanding the row. */ + readonly summary: string + } + | { readonly form: 'relay' } + | { readonly form: 'recall' } + /** * Where a message (or injected content) came from. * Merge-extensible sum type — plugins add their own `kind`s. */ export interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } +/** + * Bound for a `notice` summary. The account rides a collapsed transcript row + * and is committed to the durable log, while its inputs — task labels, goal + * objectives, tool arguments — are caller text with no length of their own. + */ +export const CONTEXT_SUMMARY_MAX_CHARS = 120 + +/** + * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}. + * @param summary - the producer's one-line account, of any length. + * @returns the account, ellipsized when it exceeds the bound. + */ +export function boundContextSummary(summary: string): string { + return summary.length <= CONTEXT_SUMMARY_MAX_CHARS + ? summary + : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…` +} + /** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */ export type MessageSource = MessageSourceMap[keyof MessageSourceMap] diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 83230f7079..f5ad71bb43 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -192,8 +192,9 @@ export interface LlmResolvedModelInfo extends LlmModelInfo { * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the * assembled block. Adapters emit usage before the terminal finish and nothing - * afterward; tool arguments remain raw JSON strings. Failures either throw or - * end with `error`/`aborted`, and consumers must handle both paths. + * afterward; tool arguments remain raw JSON strings. An adapter implementation + * may throw, but `LlmService.stream()` normalizes that failure to a terminal + * `error` or `aborted` finish before exposing it to consumers. */ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } diff --git a/packages/llm/llm/tests/adapter-failure.spec.ts b/packages/llm/llm/tests/adapter-failure.spec.ts new file mode 100644 index 0000000000..22a3abfbbd --- /dev/null +++ b/packages/llm/llm/tests/adapter-failure.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { normalizeLlmFailure } from '../src/adapter-failure.ts' + +describe('adapter failure normalization', () => { + it('contains hostile non-Error coercion', () => { + const thrown = { [Symbol.toPrimitive]: () => { throw new Error('coercion failed') } } + expect(normalizeLlmFailure(thrown)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) + }) + + it('normalizes empty primitive throws and data descriptors without values', () => { + expect(normalizeLlmFailure('')).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) + expect(normalizeLlmFailure(null)).toEqual({ message: 'null', code: 'UNKNOWN' }) + + const error = new Error('provider failed') + Object.defineProperty(error, 'failure', { get: () => ({ message: 'ignored', code: 'IGNORED' }) }) + Object.defineProperty(error, 'code', { get: () => 'IGNORED' }) + expect(normalizeLlmFailure(error)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + + const accessorCode = Object.assign(new Error('provider failed'), { + failure: { message: 'provider failed', code: 'FOREIGN' }, + }) + Object.defineProperty(accessorCode, 'code', { get: () => 'FOREIGN' }) + expect(normalizeLlmFailure(accessorCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + + const primitiveFailure = Object.assign(new Error('provider failed'), { + failure: null, + code: 'FOREIGN', + }) + expect(normalizeLlmFailure(primitiveFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + }) + + it('contains hostile Error property reflection', () => { + const withFailure = new Error('provider failed') as Error & { failure: unknown; code: string } + withFailure.failure = { message: 'provider failed', code: 'FOREIGN' } + withFailure.code = 'FOREIGN' + const hostileCode = new Proxy(withFailure, { + getOwnPropertyDescriptor(target, property) { + if (property === 'code') throw new Error('code descriptor failed') + return Reflect.getOwnPropertyDescriptor(target, property) + }, + }) + expect(normalizeLlmFailure(hostileCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + + const hostileFailure = new Proxy(new Error('provider failed'), { + getOwnPropertyDescriptor() { throw new Error('failure descriptor failed') }, + }) + expect(normalizeLlmFailure(hostileFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + }) + + it('rejects malformed or accessor-backed failure snapshots', () => { + const malformed = new Error('provider failed') as Error & { failure: unknown; code: string } + malformed.failure = { message: 'provider failed', code: 'FOREIGN', requestId: '' } + malformed.code = 'FOREIGN' + expect(normalizeLlmFailure(malformed)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + + const accessorBacked = new Error('provider failed') as Error & { failure: unknown } + accessorBacked.failure = Object.defineProperty({}, 'message', { + get() { throw new Error('failure getter failed') }, + }) + expect(normalizeLlmFailure(accessorBacked)).toEqual({ message: 'provider failed', code: 'UNKNOWN' }) + }) + + it('falls back when an Error message accessor throws', () => { + const error = new Error('provider failed') + Object.defineProperty(error, 'message', { get() { throw new Error('message getter failed') } }) + expect(normalizeLlmFailure(error)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 47d9f5ee77..990d1c38ea 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -6,11 +6,8 @@ import LlmService, { HarnessError, isContextWindowExceededError, isQuotaExceededError, - isLlmAdapterFailure, LlmAdapter, LlmError, - llmFailureOf, - llmRetryPolicyOf, ProviderRequestId, ReasoningEffortId, resolveRetryPolicy, @@ -95,6 +92,12 @@ const SCRIPT: StreamChunk[] = [ { type: 'finish', reason: { kind: 'stop' } }, ] +async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> { + const chunks: StreamChunk[] = [] + for await (const chunk of stream) chunks.push(chunk) + return chunks +} + describe('LlmService', () => { it('recognizes structured and model-capacity context-window overflow details', () => { expect(isContextWindowExceededError('context_length_exceeded maximum context length')).toBe(true) @@ -142,6 +145,8 @@ describe('LlmService', () => { it('errorChain survives non-Error values, hostile coercion, and circular causes', () => { expect(errorChain('plain string')).toBe('plain string') + expect(errorChain({ message: 'structured provider failure', code: 'SERVER' })) + .toBe('structured provider failure') expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('<unrenderable value>') const circular = new Error('outer') circular.cause = circular @@ -219,69 +224,61 @@ describe('LlmService', () => { ) }) - it('keeps the serving registration policy on an in-flight call after route replacement', async () => { + it('keeps a prepared registration and retry policy after route replacement', async () => { const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') - const entered = Promise.withResolvers<undefined>() - const release = Promise.withResolvers<undefined>() - const failure = new LlmError('old route failed', 'AUTH') - const oldAdapter = new class extends LlmAdapter { + const oldFailure = new LlmError('old route failed', 'AUTH') + const ctx = new Context() + await ctx.plugin(LlmService) + const disposeOld = ctx.llm.registerAdapter(['route'], new class extends ThrowingAdapter { override providerRetryPolicy(): typeof oldPolicy { return oldPolicy } + }(oldFailure)) + const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) - async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { - entered.resolve(undefined) - await release.promise - throw failure - } - }() - const newAdapter = new class extends ScriptedAdapter { + disposeOld() + ctx.llm.registerAdapter(['route'], new class extends ScriptedAdapter { override providerRetryPolicy(): typeof newPolicy { return newPolicy } - }(SCRIPT) - const ctx = new Context() - await ctx.plugin(LlmService) - const disposeOld = ctx.llm.registerAdapter(['route'], oldAdapter) - const stream = ctx.llm.stream({ provider: 'route', model: 'model', messages: [] }) - const outcome = (async (): Promise<unknown> => { - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - return error - } - return undefined - })() - await entered.promise + }(SCRIPT)) - disposeOld() - ctx.llm.registerAdapter(['route'], newAdapter) - release.resolve(undefined) - - expect(await outcome).toBe(failure) - expect(llmRetryPolicyOf(stream)).toBe(oldPolicy) + const chunks = await collect(prepared.stream({ ...prepared.config, messages: [] })) + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'old route failed', code: 'AUTH' }, + }, + }) + expect(prepared.retryPolicy).toBe(oldPolicy) expect(ctx.llm.providerRetryPolicy('route')).toBe(newPolicy) }) - it('throws NO_ADAPTER for unregistered providers', async () => { + it('normalizes an unregistered provider to a terminal failure', async () => { const ctx = new Context() await ctx.plugin(LlmService) - const stream = ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] }) - let caught: unknown - try { - for await (const _ of stream) { /* drain */ } - } catch (error: unknown) { - caught = error - } - expect(caught).toBeInstanceOf(LlmError) - expect((caught as LlmError).code).toBe('NO_ADAPTER') - expect((caught as LlmError).message).toContain('no adapter registered') - expect(isLlmAdapterFailure(stream, caught)).toBe(true) - expect(llmRetryPolicyOf(stream)).toBeUndefined() + + const chunks = await collect(ctx.llm.stream({ + provider: 'nope', + model: 'any-model', + messages: [], + })) + + const finish = chunks.at(-1) + expect(finish).toMatchObject({ + type: 'finish', + reason: { + kind: 'error', + failure: { code: 'NO_ADAPTER' }, + }, + }) + if (finish?.type !== 'finish' || finish.reason.kind !== 'error') throw new Error('expected error finish') + expect(finish.reason.failure.message).toContain('no adapter registered') }) - it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { + it.each(['done', 'value'] as const)('normalizes a throwing IteratorResult.%s getter', async (field) => { const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED') const result = field === 'done' ? {} : { done: false } Object.defineProperty(result, field, { get: () => { throw original } }) @@ -297,31 +294,30 @@ describe('LlmService', () => { }) const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { - return { - [Symbol.asyncIterator](): AsyncIterator<StreamChunk> { - return iterator - }, - } + return { [Symbol.asyncIterator]: () => iterator } } }() const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) + ctx.llm.registerAdapter(['test'], adapter) - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - caught = error - } + const chunks = await collect(ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) - expect(caught).toBe(original) - expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { message: `${field} getter failed`, code: 'RESULT_GETTER_FAILED' }, + }, + }) expect(cleanupLookups).toBe(0) }) - it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => { + it.each(['dispatch', 'iterator'] as const)('normalizes synchronous adapter %s failures', async (boundary) => { const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED') const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { @@ -331,339 +327,63 @@ describe('LlmService', () => { }() const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) + ctx.llm.registerAdapter(['test'], adapter) - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - caught = error - } + const chunks = await collect(ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) - expect(caught).toBe(original) - expect(isLlmAdapterFailure(stream, caught)).toBe(true) - expect(llmFailureOf(stream, caught)).toEqual({ - message: `${boundary} failed`, - code: 'BOUNDARY_FAILED', + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { message: `${boundary} failed`, code: 'BOUNDARY_FAILED' }, + }, }) }) - it('keeps structured provider facts beside a frozen third-party Error', async () => { - const original = new LlmError('provider busy', 'RATE_LIMIT', { + it('preserves structured LlmError facts in the terminal failure', async () => { + const failure = new LlmError('provider busy', 'RATE_LIMIT', { status: 429, providerRetryAfterMs: 1_500, requestId: ProviderRequestId('req-7'), }) - Object.freeze(original) const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + ctx.llm.registerAdapter(['test'], new ThrowingAdapter(failure)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - caught = error - } + const chunks = await collect(ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) - expect(caught).toBe(original) - expect(llmFailureOf(stream, caught)).toEqual({ - message: 'provider busy', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 1_500, - requestId: ProviderRequestId('req-7'), - }) - }) - - it('does not trust retry facts carried by an unknown third-party Error', async () => { - const carried = { message: 'busy', code: 'SERVER', status: 503 } - const original = Object.assign(new Error('busy'), { failure: carried }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - const facts = llmFailureOf(stream, original) - carried.status = 500 - - expect(facts).toEqual({ message: 'busy', code: 'UNKNOWN' }) - expect(Object.isFrozen(facts)).toBe(true) - expect(facts).not.toBe(carried) - }) - - it('keeps validated failure facts across package copies with matching own codes', async () => { - const original = Object.assign(new Error('provider busy'), { - code: 'RATE_LIMIT', - failure: { - message: 'provider busy', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 1_500, - requestId: 'req-cross-copy', + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1_500, + requestId: ProviderRequestId('req-7'), + }, }, }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ - message: 'provider busy', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 1_500, - requestId: 'req-cross-copy', - }) }) - it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => { - const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) - Object.defineProperty(original, 'failure', { - get() { throw new Error('SDK failure accessor must not run') }, - }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - - expect(original.code).toBe('ECONNRESET') - expect(llmFailureOf(stream, original)).toEqual({ message: 'socket closed', code: 'UNKNOWN' }) - }) - - it('keeps an SDK Error exact when its message accessor is hostile', async () => { - const original = Object.defineProperty(new Error(), 'message', { - get() { throw new Error('SDK message accessor trap') }, - }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) - }) - - it('keeps an SDK Error exact without trusting accessor-backed carried facts', async () => { - const original = Object.assign(new Error('busy'), { - failure: { message: 'busy', code: 'SERVER', status: 503 }, - }) - Object.defineProperty(original, 'code', { - get() { throw new Error('SDK code accessor must not escape') }, - }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) - }) - - it('does not trust carried facts matched only by an inherited code', async () => { - class InheritedCodeError extends Error { - get code(): string { return 'SERVER' } - } - const original = Object.assign(new InheritedCodeError('busy'), { - failure: { message: 'busy', code: 'SERVER', status: 503 }, - }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) - }) - - it('keeps an SDK Error exact when code descriptor inspection is trapped', async () => { - const target = Object.assign(new Error('busy'), { - code: 'SERVER', - failure: { message: 'busy', code: 'SERVER', status: 503 }, - }) - const original = new Proxy(target, { - getOwnPropertyDescriptor(value, property) { - if (property === 'code') throw new Error('SDK code descriptor trap') - return Reflect.getOwnPropertyDescriptor(value, property) - }, - }) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) - }) - - it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => { - const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), { - getOwnPropertyDescriptor(target, property) { - if (property === 'failure') throw new Error('SDK descriptor trap') - return Reflect.getOwnPropertyDescriptor(target, property) - }, - }) - const throwingFacts = Object.create(null) as Record<string, unknown> - Object.defineProperty(throwingFacts, 'message', { - get() { throw new Error('SDK fact getter trap') }, - }) - const carrying = (message: string, failure: unknown): HarnessError => Object.defineProperty( - new HarnessError(message, 'SERVER'), - 'failure', - { value: failure }, - ) - const factGetter = carrying('fact getter failed', throwingFacts) - const malformed = carrying('malformed facts', { message: 'provider busy', code: 'SERVER', requestId: 1 }) - const primitive = carrying('primitive facts', 1) - const nullFacts = carrying('null facts', null) - const mismatched = carrying('mismatched facts', { message: 'busy', code: 'RATE_LIMIT' }) - - for (const [original, expectedMessage] of [ - [propertyTrap, 'descriptor trapped'], - [factGetter, 'fact getter failed'], - [malformed, 'malformed facts'], - [primitive, 'primitive facts'], - [nullFacts, 'null facts'], - [mismatched, 'mismatched facts'], - ] as const) { - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ message: expectedMessage, code: 'SERVER' }) - } - }) - - it('retains a stable code from a HarnessError without requiring LlmError facts', async () => { - const original = new HarnessError('stable adapter failure', 'ADAPTER_STABLE') - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) - const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toBe(original) - expect(llmFailureOf(stream, original)).toEqual({ - message: 'stable adapter failure', - code: 'ADAPTER_STABLE', - }) - expect(llmFailureOf(stream, 'not an Error')).toBeUndefined() - expect(llmFailureOf({ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator]() }, original)).toBeUndefined() - }) - - it('keeps a nested adapter failure scoped to the nested model call', async () => { - const original = new LlmError('nested provider failed', 'NESTED_FAILED') - const outer = new RecordingAdapter(SCRIPT) - const nested = new ThrowingAdapter(original) - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['outer'], outer) - ctx.llm.registerAdapter(['nested'], nested) - let nestedStream: AsyncIterable<StreamChunk> | undefined - ctx.on('llm/stream', (options, next) => { - if (options.provider !== 'outer') return next() - return (async function* () { - nestedStream = ctx.llm.stream({ provider: 'nested', model: 'nested', messages: [] }) - yield * nestedStream - })() - }) - - const outerStream = ctx.llm.stream({ provider: 'outer', model: 'outer', messages: [] }) - let caught: unknown - try { - for await (const _chunk of outerStream) { /* drain */ } - } catch (error: unknown) { - caught = error - } - - expect(caught).toBe(original) - expect(nestedStream).toBeDefined() - expect(isLlmAdapterFailure(nestedStream!, caught)).toBe(true) - expect(isLlmAdapterFailure(outerStream, caught)).toBe(false) - expect(outer.lastOptions).toBeUndefined() - }) - - it('keeps call scopes distinct when middleware reuses an iterable', async () => { - const firstFailure = new LlmError('first provider failed', 'FIRST_FAILED') - const secondFailure = new LlmError('second provider failed', 'SECOND_FAILED') - const delegates: AsyncIterable<StreamChunk>[] = [] - const shared: AsyncIterable<StreamChunk> = { - [Symbol.asyncIterator](): AsyncIterator<StreamChunk> { - const delegate = delegates.shift() - if (delegate === undefined) throw new Error('shared stream has no call delegate') - return delegate[Symbol.asyncIterator]() - }, - } - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['first'], new ThrowingAdapter(firstFailure)) - ctx.llm.registerAdapter(['second'], new ThrowingAdapter(secondFailure)) - ctx.on('llm/stream', (_options, next) => { - delegates.push(next()) - return shared - }) - - const firstStream = ctx.llm.stream({ provider: 'first', model: 'first', messages: [] }) - const secondStream = ctx.llm.stream({ provider: 'second', model: 'second', messages: [] }) - const catchFailure = async (stream: AsyncIterable<StreamChunk>): Promise<unknown> => { - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - return error - } - return new Error('expected adapter to fail') - } - - expect(firstStream).not.toBe(secondStream) - const firstCaught = await catchFailure(firstStream) - expect(firstCaught).toBe(firstFailure) - expect(isLlmAdapterFailure(firstStream, firstCaught)).toBe(true) - expect(isLlmAdapterFailure(secondStream, firstCaught)).toBe(false) - const secondCaught = await catchFailure(secondStream) - expect(secondCaught).toBe(secondFailure) - expect(isLlmAdapterFailure(secondStream, secondCaught)).toBe(true) - expect(isLlmAdapterFailure(firstStream, secondCaught)).toBe(false) - expect(delegates).toHaveLength(0) - }) - - it('propagates a rejected next promptly without awaiting a non-settling return', async () => { - const original = new LlmError('provider failed', 'PROVIDER_FAILED') - let cleanupCalls = 0 + it('normalizes arbitrary adapter rejections without throwing them downstream', async () => { const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { return { [Symbol.asyncIterator](): AsyncIterator<StreamChunk> { return { - next: () => Promise.reject(original), - return: () => { - cleanupCalls += 1 - return new Promise<IteratorResult<StreamChunk>>(() => {}) - }, + // Third-party adapters can reject with arbitrary values. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + next: () => Promise.reject('plain provider failure'), } }, } @@ -671,30 +391,73 @@ describe('LlmService', () => { }() const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) + ctx.llm.registerAdapter(['test'], adapter) - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - const failure = (async (): Promise<unknown> => { - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - return error - } - return new Error('expected adapter iteration to fail') - })() - let timer: ReturnType<typeof setTimeout> | undefined - const timeout = new Promise<Error>((resolve) => { - timer = setTimeout(() => { resolve(new Error('adapter failure did not settle promptly')) }, 100) + const chunks = await collect(ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) + + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'plain provider failure', code: 'UNKNOWN' }, + }, }) - const caught = await Promise.race([failure, timeout]) - if (timer !== undefined) clearTimeout(timer) - - expect(caught).toBe(original) - expect(isLlmAdapterFailure(stream, caught)).toBe(true) - expect(cleanupCalls).toBe(0) }) - it('awaits one adapter return on downstream close and leaves its rejection unclassified', async () => { + it('maps adapter failure to aborted when the request signal is aborted', async () => { + const controller = new AbortController() + controller.abort('cancelled') + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test'], new ThrowingAdapter(new Error('stopped'))) + + const chunks = await collect(ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + signal: controller.signal, + })) + + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + reason: { kind: 'aborted', failure: { message: 'stopped' } }, + }) + }) + + it('leaves middleware and consumer failures thrown', async () => { + const middlewareFailure = new Error('middleware failed') + const middlewareCtx = new Context() + await middlewareCtx.plugin(LlmService) + middlewareCtx.llm.registerAdapter(['test'], new ScriptedAdapter(SCRIPT)) + middlewareCtx.on('llm/stream', () => (async function* () { + throw middlewareFailure + })()) + await expect(collect(middlewareCtx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + }))).rejects.toBe(middlewareFailure) + + const consumerFailure = new Error('consumer failed') + const consumerCtx = new Context() + await consumerCtx.plugin(LlmService) + consumerCtx.llm.registerAdapter(['test'], new ScriptedAdapter(SCRIPT)) + await expect((async () => { + for await (const _chunk of consumerCtx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) { + throw consumerFailure + } + })()).rejects.toBe(consumerFailure) + }) + + it('awaits adapter cleanup on downstream close and leaves cleanup failure thrown', async () => { const cleanup = new Error('cleanup failed') let cleanupCalls = 0 const adapter = new class extends LlmAdapter { @@ -714,22 +477,19 @@ describe('LlmService', () => { }() const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) + ctx.llm.registerAdapter(['test'], adapter) - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) break - } catch (error: unknown) { - caught = error - } - - expect(caught).toBe(cleanup) - expect(isLlmAdapterFailure(stream, caught)).toBe(false) + await expect((async () => { + for await (const _chunk of ctx.llm.stream({ + provider: 'test', + model: 'test', + messages: [], + })) break + })()).rejects.toBe(cleanup) expect(cleanupCalls).toBe(1) }) - it('allows downstream close when the adapter iterator has no return method', async () => { + it('allows downstream close when an adapter iterator has no return method', async () => { const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { return { @@ -741,66 +501,9 @@ describe('LlmService', () => { }() const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) + ctx.llm.registerAdapter(['test'], adapter) - let chunks = 0 - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { - chunks += 1 - break - } - - expect(chunks).toBe(1) - }) - - it('normalizes and tags non-Error adapter failures once', async () => { - const adapter = new class extends LlmAdapter { - stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { - return { - [Symbol.asyncIterator](): AsyncIterator<StreamChunk> { - // Third-party adapters can reject with arbitrary values. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - return { next: () => Promise.reject('plain provider failure') } - }, - } - } - }() - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], adapter) - - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) { /* drain */ } - } catch (error: unknown) { - caught = error - } - - expect(caught).toBeInstanceOf(HarnessError) - expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' }) - expect(isLlmAdapterFailure(stream, caught)).toBe(true) - }) - - it('does not tag a failure thrown downstream while consuming adapter output', async () => { - const downstream = new Error('consumer failed') - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) - - const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) - let caught: unknown - try { - for await (const _chunk of stream) throw downstream - } catch (error: unknown) { - caught = error - } - - expect(caught).toBe(downstream) - expect(isLlmAdapterFailure(stream, caught)).toBe(false) - expect(isLlmAdapterFailure(new ScriptedAdapter(SCRIPT).stream({ - provider: 'unbound', model: 'unbound', messages: [], - }), caught)).toBe(false) - expect(isLlmAdapterFailure(stream, 'consumer failed')).toBe(false) + for await (const _chunk of ctx.llm.stream({ provider: 'test', model: 'test', messages: [] })) break }) it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { @@ -1119,19 +822,34 @@ describe('LlmService', () => { expect(Object.isFrozen(prepared.config)).toBe(true) expect(Object.isFrozen(prepared.adapterDefaults)).toBe(true) expect(prepared.adapterDefaults).toEqual({ reasoningEffort: true }) - const stream = prepared.stream({ + expect(() => prepared.stream({ ...prepared.config, model: 'other', messages: [], - }) - - await expect((async () => { - for await (const _chunk of stream) { /* drain */ } - })()).rejects.toMatchObject({ code: 'INVALID_PREPARED_CALL' }) + })).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' })) + await collect(prepared.stream({ + ...prepared.config, + messages: [], + })) expect(() => prepared.stream({ ...prepared.config, messages: [], })).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' })) + + const late = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + const lateOptions = { ...late.config, messages: [] } + const lateStream = late.stream(lateOptions) + lateOptions.model = 'other' + expect(await collect(lateStream)).toContainEqual({ + type: 'finish', + reason: { + kind: 'error', + failure: { + message: 'prepared LLM call config changed before adapter dispatch', + code: 'INVALID_PREPARED_CALL', + }, + }, + }) }) it('reuses one exact-model lookup for prepared config and context metadata', async () => { diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 3b895b9852..5c5a597802 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: 701893b342f9a93a75bec175634b1054f3d17151 -README.zh.md: a5844e8788422bba669632ed587fb87e1e2a1e58 +README.md: 8f868f25f3c4caf1fdab5b50965aab41efecf5af +README.zh.md: 3621105ff35606b62b0587063038116b4772c6cf diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 701893b342..8f868f25f3 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -23,19 +23,23 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket ## Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers two units through an optional child fiber. +When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again. -`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage. +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage. -Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior. +`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. + +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. + +All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior. ### Context occupancy is an approximation, by design -`pressureTokens` and `contextWindow` are independent last-wins fields and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's pressure until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now. +The occupancy fields are independent last-wins records and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's sample until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now — `projectedTokens` carries that sample forward over the surface's movement, but its anchor is still the older request. -This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. The TUI status line has always computed occupancy the same way, dividing a `measure()` total by a separately-resolved capacity for the selected model. +This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. A UI computes occupancy by dividing measured pressure by the separately resolved capacity for the selected model. Making the pair atomic was tried and rejected: it required a transient non-replayable wire frame, which needed lifecycle fencing against cross-stream reordering and left occupancy blank after every reconnect. The [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) records that comparison. Consumers that need an exact same-boundary figure should call `measure()` at their own request boundary rather than read this projection. @@ -62,4 +66,3 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks. - **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation. - **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream. -- **The TUI and browser fixture retain parallel folds** — `tokenUsage` owns durable session-projection semantics; the TUI keeps its live per-step map because its composition does not mount the generic projection seam, while the browser fixture mirrors the unit for standalone demo data. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index a5844e8788..3621105ff3 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -23,21 +23,25 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 ## 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册两个单元。 +当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。 `tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。 -`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。 +`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。 -两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。 +`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到又跑完一整轮为止。占用率展示读取 `projectedTokens`。 + +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——与 `measure()` 运行的位置折叠是同一份——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点恰好把这些明细行仍然带着的误差排除在外(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 + +三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。 ### 上下文占用率是刻意为之的近似值 -`pressureTokens` 与 `contextWindow` 是两个各自后者胜的独立字段,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层。 +这些占用率字段各自后者胜、彼此独立,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的样本配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层——`projectedTokens` 把该样本沿表层的增减推进到当下,但它的锚点仍然是那个较早的请求。 -这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。TUI 状态行一直以同样的方式计算占用率,即用 `measure()` 总量除以为所选模型单独解析出的容量。 +这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。 -让这对值保持原子已经尝试过并被否决:它需要一个临时且不可回放的协议帧,进而需要针对跨流重排序的生命周期栅栏,还会让占用率在每次重连后变为空白。[Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md)记录了这项对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。 +让这对值保持原子已经尝试过并被否决:它需要一个临时且不可回放的协议帧,进而需要针对跨流重排序的生命周期栅栏,还会让占用率在每次重连后变为空白。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md)记录了这项对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。 ## 组合 @@ -62,4 +66,3 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 - **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。 - **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。 - **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。 -- **TUI 与浏览器 fixture 仍保留并行 fold**:`tokenUsage` 拥有持久会话投影语义;TUI 的组合未挂载通用投影 seam,因此继续维护实时的逐步骤 map,而浏览器 fixture 会为独立 demo 数据镜像该单元。 diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index b058f7cdbb..5670fe97cd 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -26,12 +26,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -43,6 +42,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts new file mode 100644 index 0000000000..036f80647f --- /dev/null +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -0,0 +1,69 @@ +/** + * Pure fold for the heuristic context-composition projection: system prompt + * and tool schemas from the newest request envelope, conversation from the + * live surface. Prices with the same shared estimator as the meter service, + * so the three figures match `measure()`'s heuristic vocabulary exactly. + */ + +import { z } from 'zod' +import { canonicalHeader } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts' +import { foldSurfaceProjection } from './surface-projection.ts' +import type { ShadowPriceClaim } from './surface-projection.ts' +// Import for the `contextBreakdown` SessionProjectionMap key merge. +import type {} from './projection.ts' + +interface ContextBreakdownState { + systemTokens: number + toolsTokens: number + messageTokens: number + /** Shadow price armed by the immediately preceding metering event. */ + claim?: ShadowPriceClaim +} + +const breakdownSchema = z.object({ + systemTokens: z.number().int().nonnegative(), + toolsTokens: z.number().int().nonnegative(), + messageTokens: z.number().int().nonnegative(), +}).strict() + +/** + * Token-meter's context-composition projection unit. + * + * Envelope figures are last-wins per `request/header`; the message figure + * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy + * projection uses — so it equals `measure().surfaceTokens` at every event + * boundary and compaction shrinks it by its logged shadow price, the way it + * shrinks the next request. The state is a fixed handful of numbers, so the + * persisted checkpoint stays O(1) over the session's life. + */ +export const contextBreakdownProjectionDefinition: +ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { + key: 'contextBreakdown', + schema: breakdownSchema, + init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }), + apply: (state, event) => { + const fold = foldSurfaceProjection(state.claim, event) + let systemTokens = state.systemTokens + let toolsTokens = state.toolsTokens + if (event.type === 'request/header') { + const header = canonicalHeader(event.data.header) + systemTokens = estimateSystemTokens(header) + toolsTokens = estimateToolsTokens(header) + } + if (systemTokens === state.systemTokens + && toolsTokens === state.toolsTokens + && fold.deltaTokens === 0 + && fold.claim === undefined + && state.claim === undefined) return state + return { + systemTokens, + toolsTokens, + messageTokens: state.messageTokens + fold.deltaTokens, + ...fold.claim === undefined ? {} : { claim: fold.claim }, + } + }, + view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }), + stateVersion: 2, +} diff --git a/packages/llm/token-meter/src/estimate.ts b/packages/llm/token-meter/src/estimate.ts new file mode 100644 index 0000000000..1e02428086 --- /dev/null +++ b/packages/llm/token-meter/src/estimate.ts @@ -0,0 +1,87 @@ +/** + * Fixed-density heuristic token pricing shared by the meter service and the + * pure context-breakdown projection, so both surfaces price identical content + * to identical numbers. + * + * @module @deepseek-ai/dsh-token-meter/estimate + */ + +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { EpochHeader } from '@deepseek-ai/dsh-session' + +/** Fixed text-density estimate used until exact tokenization is needed. */ +const CHARS_PER_TOKEN = 4 + +/** Per-block structural overhead for JSON framing and type tags. */ +const BLOCK_OVERHEAD = 4 + +/** Role-field framing overhead added to every priced message. */ +export const ROLE_OVERHEAD = 4 + +/** + * Price content blocks recursively under the fixed density heuristic. + * @param blocks - content blocks to price without mutation. + * @returns heuristic tokens including per-block structural overhead. + */ +export function estimateContent(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) + + BLOCK_OVERHEAD + break + case 'tool-result': + tokens += estimateContent(block.content) + BLOCK_OVERHEAD + break + default: + // ContentBlockMap is merge-extensible; unknown blocks retain a + // conservative structural JSON price under the fixed heuristic. + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) + } + } + return tokens +} + +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed heuristic. + */ +export function estimateMessage(message: Message): number { + return estimateContent(message.content) + ROLE_OVERHEAD +} + +/** + * Price the system-prompt part of a canonical request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic system-prompt tokens; 0 when absent. + */ +export function estimateSystemTokens(header: EpochHeader | undefined): number { + if (header?.system === undefined) return 0 + return Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD +} + +/** + * Price the tool-schema part of a canonical request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic tool-schema tokens; 0 when absent or empty. + */ +export function estimateToolsTokens(header: EpochHeader | undefined): number { + if (header?.tools === undefined || header.tools.length === 0) return 0 + return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD +} + +/** + * Price the complete non-surface request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic system plus tool tokens. + */ +export function estimateHeader(header: EpochHeader | undefined): number { + return estimateSystemTokens(header) + estimateToolsTokens(header) +} diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 1300343e99..31991b482a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -7,8 +7,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' // Type-only: resolves the optional projection registry Context seam. import type {} from '@deepseek-ai/dsh-session-projection' @@ -18,19 +18,13 @@ import type { TokenMeterConfig, TokenSurfaceNode, } from './types.ts' +import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' +import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts' +import { foldSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' -/** Fixed text-density estimate used until exact tokenization is needed. */ -const CHARS_PER_TOKEN = 4 - -/** Per-block structural overhead for JSON framing and type tags. */ -const BLOCK_OVERHEAD = 4 - -/** Role-field framing overhead added to every priced message. */ -const ROLE_OVERHEAD = 4 - interface MeasurementAnchor { readonly header: EpochHeader | undefined readonly surfaceTokens: number @@ -46,11 +40,6 @@ interface ReplayState { anchor: MeasurementAnchor | undefined } -interface PreparedSurfaceMutation { - readonly tokens: number - commit(state: ReplayState): void -} - /** Sum disjoint provider usage buckets without double-counting reasoning output. */ function usageTokens(usage: TokenUsage): number { return usage.inputTokens @@ -93,11 +82,12 @@ export class TokenMeterService extends Service { super(ctx, 'tokenMeter') validateConfigKeys(config) - // Projection registration is an optional child: headless and TUI - // compositions without the generic registry keep the meter's old shape. + // Projection registration is an optional child: compositions without the + // generic registry keep the meter's standalone read shape. ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition) projectionCtx.sessionProjections.register(contextPressureProjectionDefinition) + projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition) }) // Readers catch up independently, while eager observation bounds ordinary @@ -141,7 +131,7 @@ export class TokenMeterService extends Service { } else { baseline = { kind: 'estimated', - tokens: this._estimateHeader(header) + state.surfaceTokens, + tokens: estimateHeader(header) + state.surfaceTokens, } surfaceDeltaTokens = 0 } @@ -157,12 +147,13 @@ export class TokenMeterService extends Service { } /** - * Heuristically price one model-visible message. + * Heuristically price one model-visible message (instance face of the pure + * `estimateMessage` export from `estimate.ts`). * @param message - message to price without mutation. * @returns content and role-framing tokens under the fixed service heuristic. */ estimateMessage(message: Message): number { - return this._estimateContent(message.content) + ROLE_OVERHEAD + return estimateMessage(message) } /** Catch one session's fold up to the current durable tail. */ @@ -224,7 +215,7 @@ export class TokenMeterService extends Service { } const surface = isSurfaceEvent(event) - ? this._prepareSurfaceMutation(session, state, event) + ? foldSurfaceTokens(state.surface, event) : undefined if (event.type === 'assistant/message') { @@ -246,7 +237,7 @@ export class TokenMeterService extends Service { ) const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens const providerTokens = usageTokens(event.data.usage) - const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens + const estimatedAnchorTokens = estimateHeader(nextHeader) + anchorSurfaceTokens nextAnchor = { header: nextHeader, surfaceTokens: anchorSurfaceTokens, @@ -263,7 +254,7 @@ export class TokenMeterService extends Service { surfaceTokens: anchorSurfaceTokens, baseline: { kind: 'estimated', - tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens, + tokens: estimateHeader(nextHeader) + anchorSurfaceTokens, }, } } @@ -271,53 +262,13 @@ export class TokenMeterService extends Service { state.header = nextHeader state.stepStart = nextStepStart - if (surface !== undefined) surface.commit(state) + if (surface !== undefined) { + state.surface = surface.nodes + state.surfaceTokens += surface.deltaTokens + } state.anchor = nextAnchor } - /** Validate one surface operation and return its allocation-light commit. */ - private _prepareSurfaceMutation( - session: Session, - state: ReplayState, - event: SurfaceEvent, - ): PreparedSurfaceMutation { - const tokens = this._estimateSurfaceEvent(session, event) - const op = event.surfaceOp - if (op === 'append') { - return { - tokens, - commit(target) { - target.surface.push({ seq: event.seq, tokens }) - target.surfaceTokens += tokens - }, - } - } - - const startIdx = state.surface.findIndex(node => node.seq === op.start) - const endIdx = state.surface.findIndex(node => node.seq === op.end) - if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { - throw new Error( - `token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, - ) - } - const removedTokens = state.surface - .slice(startIdx, endIdx + 1) - .reduce((total, node) => total + node.tokens, 0) - return { - tokens, - commit(target) { - target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) - target.surfaceTokens += tokens - removedTokens - }, - } - } - - /** Price one current surface event exactly as it projects to a request. */ - private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number { - const message = session.deriveEventMessage(event) - return message === null ? 0 : this.estimateMessage(message) - } - /** * Reassemble provider output from exact chunk provenance for a usage anchor. * Missing legacy provenance conservatively treats the durable output as the @@ -355,46 +306,7 @@ export class TokenMeterService extends Service { assembler.push(sourceEvent.data.chunk) } const providerContent = assembler.blocks() - return providerContent.length === 0 ? 0 : this._estimateContent(providerContent) + ROLE_OVERHEAD - } - - /** Price content blocks recursively under the fixed density heuristic. */ - private _estimateContent(blocks: readonly ContentBlock[]): number { - let tokens = 0 - for (const block of blocks) { - switch (block.type) { - case 'text': - case 'reasoning': - tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD - break - case 'tool-call': - tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) - + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) - + BLOCK_OVERHEAD - break - case 'tool-result': - tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD - break - default: - // ContentBlockMap is merge-extensible; unknown blocks retain a - // conservative structural JSON price under the fixed heuristic. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) - } - } - return tokens - } - - /** Price the canonical non-surface request envelope. */ - private _estimateHeader(header: EpochHeader | undefined): number { - if (header === undefined) return 0 - let tokens = 0 - if (header.system !== undefined) { - tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD - } - if (header.tools !== undefined && header.tools.length > 0) { - tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD - } - return tokens + return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD } } diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index e0bbe268e1..53ae13c466 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -17,9 +17,14 @@ export const inject = ['invariants'] /** * No runtime invariant: token estimates are per-call outputs and the private * session cache is invalidated at its event mutation boundary. The package's - * projection does expose an observation stream, but its schema fixes the JSON - * payload and its pure fold replaces same-step samples; totals need not be - * monotone when a final usage sample corrects an earlier chunk. + * three projections do expose observation streams, but their schemas fix the + * JSON payloads; the usage folds replace same-step samples, so totals need not + * be monotone when a final sample corrects an earlier chunk, and the + * composition fold prices through the same `estimate.ts` heuristic as the + * measurement service and subtracts producer-logged shadow prices derived + * from that service's own nodes, which makes its message figure equal + * `measure().surfaceTokens` by construction rather than by a relation worth + * observing at runtime. */ const install: InvariantInstaller = () => {} diff --git a/packages/llm/token-meter/src/projection.ts b/packages/llm/token-meter/src/projection.ts index 2740b4954f..5c451617e9 100644 --- a/packages/llm/token-meter/src/projection.ts +++ b/packages/llm/token-meter/src/projection.ts @@ -20,14 +20,12 @@ export interface TokenUsageProjection { /** * Approximate context occupancy for a status display. * - * The two fields, when present, are deliberately NOT one atomic request - * observation: `pressureTokens` is the newest provider-reported prompt size, - * `contextWindow` the newest recorded route capacity. Switching models can - * therefore pair a fresh capacity with the previous route's pressure until the - * next request reports usage. This is an intentional trade — the value is a - * user-facing reference, not a billing or gating input — and it matches how - * the TUI status line has always computed occupancy. See the token-meter - * README for the full rationale. + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. */ export interface ContextPressureProjection { /** @@ -36,15 +34,44 @@ export interface ContextPressureProjection { * grow as the current turn streams. Absent until a provider reports usage. */ pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number /** Newest recorded route capacity; absent when no adapter advertised one. */ contextWindow?: number } +/** + * Heuristic composition of the next request's context: what the prompt is + * made of, not what it costs. All three figures use the meter's fixed + * density estimate, so they will not sum to the provider-anchored + * `projectedTokens`: the estimator systematically underprices CJK text and + * JSON schemas, which is exactly the error the anchoring in + * {@link ContextPressureProjection.projectedTokens} keeps out of the occupancy + * figure. Present these as approximations of composition, never as a total. + */ +export interface ContextBreakdownProjection { + /** Heuristic tokens of the newest request envelope's system prompt; 0 before any request. */ + systemTokens: number + /** Heuristic tokens of the newest request envelope's tool schemas; 0 before any request. */ + toolsTokens: number + /** Heuristic tokens of the current model-visible conversation surface. */ + messageTokens: number +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Provider-reported usage accumulated across the complete durable log. */ tokenUsage: TokenUsageProjection /** Newest request pressure paired with the newest known route capacity. */ contextPressure: ContextPressureProjection + /** Heuristic system/tools/message composition of the next request. */ + contextBreakdown: ContextBreakdownProjection } } diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts new file mode 100644 index 0000000000..e4dfacc254 --- /dev/null +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -0,0 +1,64 @@ +/** + * The measurement service's positional surface fold: the per-node priced + * surface `measure()` serves and compaction plans against. The projection + * units deliberately do NOT share this fold — their state must stay O(1) + * for the persisted checkpoint, so they ride `surface-projection.ts`'s + * shadow-price protocol instead. The two stay in agreement by construction: + * both price through `estimate.ts`, and every logged shadow price is derived + * from THIS fold's nodes by the replace producer. + * + * @module @deepseek-ai/dsh-token-meter/surface-fold + */ + +import { deriveEventMessage } from '@deepseek-ai/dsh-session' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { TokenSurfaceNode } from './types.ts' +import { estimateMessage } from './estimate.ts' + +/** One surface event's placement and cost against the surface preceding it. */ +export interface SurfaceTokenFold { + /** Heuristic price of the event's own message; 0 when it derives none. */ + readonly tokens: number + /** The surface after the event, detached from the input. */ + readonly nodes: TokenSurfaceNode[] + /** Signed change in the surface total: `tokens` minus anything shadowed. */ + readonly deltaTokens: number +} + +/** + * Fold one surface event onto a priced surface. + * + * Total and allocation-fresh: the caller assigns the result rather than + * mutating in place, so a throw here leaves the caller's state untouched and + * the same malformed event fails identically on every retry. + * @param nodes - the priced surface preceding this event, in model-visible order. + * @param event - the surface event to place. + * @returns the event's price, the next surface, and the signed total delta. + * @throws when a replacement names a range absent from `nodes` — committed + * logs are surface-validated at append time, so an unresolvable range is log + * corruption and must fail loud rather than skip the event. + */ +export function foldSurfaceTokens( + nodes: readonly TokenSurfaceNode[], + event: SurfaceEvent, +): SurfaceTokenFold { + const message = deriveEventMessage(event) + const tokens = message === null ? 0 : estimateMessage(message) + const op = event.surfaceOp + if (op === 'append') { + return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens } + } + const startIdx = nodes.findIndex(node => node.seq === op.start) + const endIdx = nodes.findIndex(node => node.seq === op.end) + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + throw new Error( + `token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, + ) + } + const removed = nodes + .slice(startIdx, endIdx + 1) + .reduce((total, node) => total + node.tokens, 0) + const next = [...nodes] + next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) + return { tokens, nodes: next, deltaTokens: tokens - removed } +} diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts new file mode 100644 index 0000000000..dcc8181370 --- /dev/null +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -0,0 +1,84 @@ +/** + * The O(1) surface-token fold shared by the token-meter projection units. + * + * A projection state must stay bounded — the persisted projection cache + * checkpoints every unit's whole state, so carrying the priced surface + * (one node per model-visible message) would grow a checkpoint without + * bound over the session's life. Instead, replacements ride the compact + * seam's shadow-price protocol: the metering event immediately before a + * surface `replace` (`compact/summary` or `compact/prune`) states the + * heuristic price of the exact replaced range, so the fold keeps a running + * total plus at most one pending claim and never retains per-node prices. + * The counts are exact by construction: producers derive them from the same + * fixed estimator this module prices appends with. + * + * @module @deepseek-ai/dsh-token-meter/surface-projection + */ + +import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +// Type-only: the `compact/*` SessionEventMap merges (shadow-price events). +import type {} from '@deepseek-ai/dsh-compact' +import { estimateMessage } from './estimate.ts' + +/** + * One armed shadow price: the heuristic tokens of the surface range the + * IMMEDIATELY following event replaces. Plain JSON — it is part of the + * persisted unit state while armed. + */ +export interface ShadowPriceClaim { + /** Declared inclusive first surface-node seq of the priced range. */ + start: number + /** Declared inclusive last surface-node seq of the priced range. */ + end: number + /** Heuristic tokens of the priced range under the fixed estimator. */ + tokens: number +} + +/** One event's effect on a running surface-token total. */ +export interface SurfaceTokensFold { + /** Signed change in the surface total; 0 for events off the surface. */ + readonly deltaTokens: number + /** Claim to carry into the next event; undefined when none survives. */ + readonly claim: ShadowPriceClaim | undefined +} + +/** + * Fold one committed event onto a running surface-token total. + * + * A shadow-price event arms a claim; any other event expires it, and a + * surface `replace` must consume a claim naming its exact range — the + * producers append the metering event and the replacement synchronously + * adjacent, so a surviving claim always prices the very next event. + * @param claim - the claim armed by the immediately preceding event, if any. + * @param event - the next committed session event. + * @returns the signed token delta and the claim state after this event. + * @throws when a replacement arrives without a claim for its exact range — + * every in-repo replace producer meters its replacement, so an unpriced + * replacement is a shadow-price contract violation and must fail loud + * rather than let the total drift. + */ +export function foldSurfaceProjection( + claim: ShadowPriceClaim | undefined, + event: SessionEvent, +): SurfaceTokensFold { + if (event.type === 'compact/summary' || event.type === 'compact/prune') { + const { shadowedRange, shadowedTokenCount } = event.data + return { + deltaTokens: 0, + claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount }, + } + } + if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined } + const message = deriveEventMessage(event) + const tokens = message === null ? 0 : estimateMessage(message) + const op = event.surfaceOp + if (op === 'append') return { deltaTokens: tokens, claim: undefined } + if (claim === undefined || claim.start !== op.start || claim.end !== op.end) { + throw new Error( + `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price` + + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`), + ) + } + return { deltaTokens: tokens - claim.tokens, claim: undefined } +} diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index be94993f35..779bd8e271 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,7 +6,7 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' -export type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' +export type { ContextBreakdownProjection, ContextPressureProjection, TokenUsageProjection } from './projection.ts' /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record<string, never> diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 302485da8e..0d5db509b5 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -4,8 +4,11 @@ import { z } from 'zod' import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' +import { foldSurfaceProjection } from './surface-projection.ts' +import type { ShadowPriceClaim } from './surface-projection.ts' interface UsageSample { turn: number @@ -60,6 +63,7 @@ const projectionSchema = z.object({ // `number | undefined` where the interface declares absent-or-number fields. const pressureSchema = z.object({ pressureTokens: z.number().int().nonnegative().optional(), + projectedTokens: z.number().int().nonnegative().optional(), contextWindow: z.number().int().positive().optional(), }).strict() as unknown as z.ZodType<ContextPressureProjection> @@ -67,6 +71,29 @@ const pressureSchema = z.object({ const pressureFrom = (usage: TokenUsage): number => usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) +/** The usage a chunk or finalized message reports for its step, if any. */ +const usageOf = (event: SessionEvent): TokenUsage | undefined => + event.type === 'assistant/chunk' && event.data.chunk.type === 'usage' + ? event.data.chunk.usage + : event.type === 'assistant/message' + ? event.data.usage + : undefined + +/** + * Context-occupancy state: the two independent last-wins records plus the + * O(1) running surface total needed to carry the newest sample forward. + */ +interface ContextPressureState { + contextWindow?: number + pressureTokens?: number + /** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */ + surfaceTokens: number + /** {@link surfaceTokens} at the newest usage sample; absent until one lands. */ + sampledSurfaceTokens?: number + /** Shadow price armed by the immediately preceding metering event. */ + claim?: ShadowPriceClaim +} + /** * Token-meter's session projection unit. * @@ -115,39 +142,64 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { /** * Token-meter's context-occupancy projection unit. * - * Two independent last-wins slots: the newest usage sample supplies the + * Independent last-wins slots: the newest usage sample supplies the provider * numerator, the newest `request/context` record the denominator. Both are * whole values, so replay order alone decides the result and no cross-field * consistency is claimed — the pair is explicitly not one atomic request * observation (see {@link ContextPressureProjection}). * - * The numerator is prompt-side only, so it holds still while a turn streams - * and steps forward once the next request reports its usage. + * `pressureTokens` is prompt-side only, so it holds still while a turn streams + * and steps forward once the next request reports its usage. Because nothing + * but a request reports usage, it also cannot see a compaction: the fold + * therefore carries a running surface total alongside it and publishes + * `projectedTokens` — the sample plus the surface's signed movement since it + * was taken — so occupancy answers for the next request rather than the last + * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1) + * and a replacement shrinks it by its logged shadow price. A usage sample is + * stamped BEFORE the same event joins the surface, so an `assistant/message` + * anchors against the surface its own request saw. */ export const contextPressureProjectionDefinition: -ProjectionDefinition<'contextPressure', ContextPressureProjection> = { +ProjectionDefinition<'contextPressure', ContextPressureState> = { key: 'contextPressure', schema: pressureSchema, - init: () => ({}), + init: () => ({ surfaceTokens: 0 }), apply: (state, event) => { + const fold = foldSurfaceProjection(state.claim, event) + let next = state if (event.type === 'request/context') { const contextWindow = event.data.contextWindow - if (contextWindow === state.contextWindow) return state - if (contextWindow !== undefined) return { ...state, contextWindow } - const { contextWindow: _removed, ...withoutContextWindow } = state - return withoutContextWindow + if (contextWindow !== state.contextWindow) { + if (contextWindow !== undefined) { + next = { ...next, contextWindow } + } else { + const { contextWindow: _removed, ...withoutContextWindow } = next + next = withoutContextWindow + } + } } - const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage' - ? event.data.chunk.usage - : event.type === 'assistant/message' - ? event.data.usage - : undefined - if (usage === undefined) return state - const pressureTokens = pressureFrom(usage) - return pressureTokens === state.pressureTokens - ? state - : { ...state, pressureTokens } + const usage = usageOf(event) + if (usage !== undefined) { + const pressureTokens = pressureFrom(usage) + if (pressureTokens !== next.pressureTokens || next.sampledSurfaceTokens !== next.surfaceTokens) { + next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens } + } + } + if (fold.deltaTokens !== 0) { + next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens } + } + // A defined fold.claim is always freshly built, so presence decides claim + // bookkeeping: no claim before or after this event leaves `next` as is. + if (state.claim === undefined && fold.claim === undefined) return next + const { claim: _expired, ...withoutClaim } = next + return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim } }, - view: state => state, - stateVersion: 2, + view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({ + ...contextWindow === undefined ? {} : { contextWindow }, + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...pressureTokens === undefined || sampledSurfaceTokens === undefined + ? {} + : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, + }), + stateVersion: 4, } diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts new file mode 100644 index 0000000000..b7e4850fd4 --- /dev/null +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -0,0 +1,307 @@ +// contextBreakdown projection: heuristic system/tools/message composition, +// plus the shared estimator's pricing branches. + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' +import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' +import { + estimateContent, + estimateHeader, + estimateMessage, + estimateSystemTokens, + estimateToolsTokens, +} from '../src/estimate.ts' + +const CONFIG = { provider: 'test', model: 'test-model' } + +const TOOLS: ToolSchema[] = [{ + name: 'bash', + description: 'run a command', + parameters: { type: 'object', properties: {} }, +}] + +async function harness(): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(TokenMeterService) + return { ctx, session: ctx.sessions.create() } +} + +const projected = (ctx: Context, session: Session): ContextBreakdownProjection => { + const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown + if (value === undefined) throw new Error('contextBreakdown projection is not registered') + return value +} + +function appendUser(session: Session, text: string): number { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq +} + +/** + * Meter one upcoming replacement the way compact-basic does: price the + * replaced span from the measurement service's own nodes and log the + * shadow-price event directly before the replace. + */ +function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { + const nodes = ctx.tokenMeter.measure(session).nodes + const startIdx = nodes.findIndex(node => node.seq === start) + const endIdx = nodes.findIndex(node => node.seq === end) + const shadowed = nodes.slice(startIdx, endIdx + 1) + session.append('compact/summary', { + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start, end }, + shadowedSeqs: shadowed.map(node => node.seq), + shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0), + provider: 'mock', + model: 'mock', + }) +} + +describe('contextBreakdown session projection', () => { + it('serves zeros for an empty log', async () => { + const { ctx, session } = await harness() + expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }) + }) + + it('prices the newest envelope last-wins and pushes no change for a restated one', async () => { + const { ctx, session } = await harness() + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'initial', + }) + expect(projected(ctx, session)).toEqual({ + systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }), + toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }), + messageTokens: 0, + }) + + const changed: string[] = [] + ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) }) + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'change', + }) + session.append('todo/write', { todos: [] }) + expect(changed).not.toContain('contextBreakdown') + + // A system-less, tool-less envelope prices back to zero. + session.append('request/header', { header: { config: CONFIG }, reason: 'change' }) + expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }) + }) + + it('sums surface appends and skips an empty-content assistant message', async () => { + const { ctx, session } = await harness() + appendUser(session, 'abcd') + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage: { inputTokens: 9, outputTokens: 0 }, + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + session.append('step/end', { turn: 1, step: 1 }) + // 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant + // message derives to no transcript entry and adds nothing. + expect(projected(ctx, session).messageTokens).toBe(9) + }) + + it('shrinks the message figure when a metered replacement compacts the surface', async () => { + const { ctx, session } = await harness() + const first = appendUser(session, 'before compaction, a longer message') + const second = appendUser(session, 'and a second entry') + const summary = createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + appendSummaryMeter(ctx, session, first, second) + session.append('user/message', summary, { + surfaceOp: { op: 'replace', start: first, end: second }, + sourceEventSeqs: [first, second], + }) + expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary)) + }) + + it('keeps the message figure equal to the service surface across appends and a compaction', async () => { + const { ctx, session } = await harness() + // The panel's composition rows and `measure()` answer the same question in + // the same vocabulary; one shared fold is what makes that true. + const agree = (): number => { + const messageTokens = projected(ctx, session).messageTokens + expect(messageTokens).toBe(ctx.tokenMeter.measure(session).surfaceTokens) + return messageTokens + } + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'initial', + }) + expect(agree()).toBe(0) + + const question = appendUser(session, 'a first question, long enough to price above zero') + session.append('step/start', { turn: 1, step: 1 }) + const answer = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'a considered answer' }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage: { inputTokens: 40, outputTokens: 7 }, + }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq + session.append('step/end', { turn: 1, step: 1 }) + const grown = agree() + expect(grown).toBeGreaterThan(0) + + appendSummaryMeter(ctx, session, question, answer) + // The armed shadow price must not move the published figure by itself. + expect(agree()).toBe(grown) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: answer }, + sourceEventSeqs: [question, answer], + }) + expect(agree()).toBeLessThan(grown) + }) + + it('fails loud on a replacement without an adjacent matching shadow price', () => { + const definition = contextBreakdownProjectionDefinition + const replace = (start: number, end: number): SessionEvent => ({ + type: 'user/message', + seq: 9, + time: 0, + data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [start, end], + } as unknown as SessionEvent) + const append = (seq: number): SessionEvent => ({ + type: 'user/message', + seq, + time: 0, + data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + surfaceOp: 'append', + } as unknown as SessionEvent) + const meter = (start: number, end: number, seq: number): SessionEvent => ({ + type: 'compact/prune', + seq, + time: 0, + data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 }, + } as unknown as SessionEvent) + let state = definition.init() + state = definition.apply(state, append(1)) + state = definition.apply(state, append(3)) + // No metering event at all. + expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price') + // A claim for a different range does not price this replacement. + const mismatched = definition.apply(state, meter(1, 1, 8)) + expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') + // A claim expires after one intervening event instead of lingering. + let expired = definition.apply(state, meter(1, 3, 8)) + expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) + expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price') + // The armed claim prices exactly the next event's matching replacement. + const armed = definition.apply(state, meter(1, 3, 8)) + expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens - 5 + estimateMessage( + createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + )) + }) + + it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => { + const { ctx, session } = await harness() + const first = appendUser(session, 'the first of many messages') + for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) + const last = appendUser(session, 'the last message before compaction') + const stateKeys = (): string[] => { + const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown'] + if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing') + return Object.keys(row.val as Record<string, unknown>).sort() + } + // Growth adds no per-node bookkeeping to the durable state. + expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens']) + const shadowed = session.surface.nodes.slice( + session.surface.nodes.indexOf(first), + session.surface.nodes.indexOf(last) + 1, + ) + appendSummaryMeter(ctx, session, first, last) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: first, end: last }, + sourceEventSeqs: [...shadowed], + }) + expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens']) + expect(projected(ctx, session).messageTokens) + .toBe(ctx.tokenMeter.measure(session).surfaceTokens) + }) + + it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const meterFiber = await ctx.plugin(TokenMeterService) + const session = ctx.sessions.create() + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.' }, + reason: 'initial', + }) + appendUser(session, 'abcd') + const checkpoint = JSON.parse(JSON.stringify( + ctx.sessionProjections.checkpoint(session), + )) as ReturnType<typeof ctx.sessionProjections.checkpoint> + + await meterFiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown') + + await ctx.plugin(TokenMeterService) + expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({ + systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }), + toolsTokens: 0, + messageTokens: 9, + }) + }) +}) + +describe('shared estimator', () => { + it('prices every content-block shape under the fixed heuristic', () => { + expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5) + expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6) + expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7) + expect(estimateContent([{ + type: 'tool-result', toolCallId: 'c' as never, + content: [{ type: 'text', text: 'abcd' }], + }])).toBe(9) + const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock + expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4)) + }) + + it('prices envelope parts independently and absent parts to zero', () => { + expect(estimateSystemTokens(undefined)).toBe(0) + expect(estimateSystemTokens({ config: CONFIG })).toBe(0) + expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6) + expect(estimateToolsTokens(undefined)).toBe(0) + expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0) + expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS })) + .toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4) + expect(estimateHeader(undefined)).toBe(0) + expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS })) + .toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4) + }) +}) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 1a4a4a5680..7009cc6a33 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -147,7 +147,7 @@ describe('TokenMeterService pricing', () => { it('returns a detached deeply immutable empty measurement', () => { const service = meter() - const session = new Session(SessionId('empty')) + const session = Session.create(SessionId('empty')) const result = service.measure(session) expect(result).toEqual({ logRevision: 0, @@ -168,7 +168,7 @@ describe('TokenMeterService pricing', () => { it('keeps an earlier unified snapshot detached from later replay', () => { const service = meter() - const session = new Session(SessionId('detached')) + const session = Session.create(SessionId('detached')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' }, @@ -200,7 +200,7 @@ describe('TokenMeterService pricing', () => { it('prices header, tools, and surface when no reusable usage exists', () => { const service = meter() - const session = new Session(SessionId('heuristic')) + const session = Session.create(SessionId('heuristic')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'question' }], source: { kind: 'user' }, @@ -218,7 +218,7 @@ describe('TokenMeterService pricing', () => { it('keeps request-header overrides out of the returned surface', () => { const service = meter() - const session = new Session(SessionId('override-surface')) + const session = Session.create(SessionId('override-surface')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'question' }], source: { kind: 'user' }, @@ -246,7 +246,7 @@ describe('replay anchors and surface folds', () => { it('uses disjoint provider usage and signed durable-output rewrites', () => { const service = meter() - const session = new Session(SessionId('usage')) + const session = Session.create(SessionId('usage')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'before' }], source: { kind: 'user' }, @@ -267,7 +267,7 @@ describe('replay anchors and surface folds', () => { it('selects a heuristic anchor when provider usage would undercut its scale', () => { const service = meter() - const session = new Session(SessionId('low-usage-anchor')) + const session = Session.create(SessionId('low-usage-anchor')) const system = 'system context' const requestHeader = header('deepseek-v4-flash', { system }) appendSuccessfulCall(session, requestHeader, { @@ -297,7 +297,7 @@ describe('replay anchors and surface folds', () => { it('uses an estimated anchor when provider usage is absent', () => { const service = meter() - const session = new Session(SessionId('missing-usage')) + const session = Session.create(SessionId('missing-usage')) appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), { providerText: 'provider', durableText: 'rewritten', @@ -314,8 +314,8 @@ describe('replay anchors and surface folds', () => { }) it('distinguishes explicit empty provenance from absent legacy provenance', () => { - const explicit = new Session(SessionId('explicit-empty')) - const legacy = new Session(SessionId('legacy-absent')) + const explicit = Session.create(SessionId('explicit-empty')) + const legacy = Session.create(SessionId('legacy-absent')) appendSuccessfulCall(explicit, header('deepseek-v4-flash'), { durableText: 'listener injected text', providerText: '', @@ -335,7 +335,7 @@ describe('replay anchors and surface folds', () => { it('keeps only the latest successful request anchor across model switches', () => { const service = meter() - const session = new Session(SessionId('switch')) + const session = Session.create(SessionId('switch')) const alphaHeader = header('alpha', { system: 'same envelope' }) appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 }) @@ -356,7 +356,7 @@ describe('replay anchors and surface folds', () => { it('invalidates usage for any canonical envelope change or explicit override', () => { const service = meter() - const session = new Session(SessionId('envelope')) + const session = Session.create(SessionId('envelope')) const anchoredHeader = header('deepseek-v4-flash', { system: 'one' }) appendSuccessfulCall(session, anchoredHeader, { usage: USAGE }) expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage') @@ -375,7 +375,7 @@ describe('replay anchors and surface folds', () => { }) it('folds the latest full header snapshot into the effective envelope', () => { - const session = new Session(SessionId('header-snapshot')) + const session = Session.create(SessionId('header-snapshot')) appendHeader(session, header('deepseek-v4-flash')) session.append('request/header', { header: header('deepseek-v4-pro'), @@ -388,7 +388,7 @@ describe('replay anchors and surface folds', () => { it('replays seeded append and replace operations with signed deltas', () => { const service = meter() - const original = new Session(SessionId('surface-original')) + const original = Session.create(SessionId('surface-original')) appendSuccessfulCall(original, header('deepseek-v4-flash'), { usage: USAGE, providerText: 'long provider answer '.repeat(100), @@ -397,7 +397,7 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const seeded = new Session(SessionId('surface-seeded'), original.events) + const seeded = Session.create(SessionId('surface-seeded'), original.events) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) @@ -423,7 +423,7 @@ describe('replay anchors and surface folds', () => { }) it('prices an empty assistant surface anchor as zero', () => { - const session = new Session(SessionId('empty-assistant')) + const session = Session.create(SessionId('empty-assistant')) appendSuccessfulCall(session, header('deepseek-v4-flash'), { providerText: '', durableText: '', @@ -444,7 +444,7 @@ describe('malformed replay and listener lifecycle', () => { } it('rejects an assistant without its step boundary transactionally', () => { - const session = new Session(SessionId('bad-step')) + const session = Session.create(SessionId('bad-step')) appendHeader(session, header('deepseek-v4-flash')) session.append('assistant/message', { turn: 1, @@ -462,7 +462,7 @@ describe('malformed replay and listener lifecycle', () => { }) it('clears completed step boundaries and rejects overlapping or late step events', () => { - const overlapping = new Session(SessionId('overlapping-step')) + const overlapping = Session.create(SessionId('overlapping-step')) overlapping.append('step/start', { turn: 1, step: 1 }) overlapping.append('step/start', { turn: 1, step: 2 }) expectRepeatedFailure( @@ -471,7 +471,7 @@ describe('malformed replay and listener lifecycle', () => { /arrived before turn 1\/step 1 ended/, ) - const late = new Session(SessionId('late-assistant')) + const late = Session.create(SessionId('late-assistant')) late.append('step/start', { turn: 1, step: 1 }) appendHeader(late, header('deepseek-v4-flash')) late.append('step/end', { turn: 1, step: 1 }) @@ -493,7 +493,7 @@ describe('malformed replay and listener lifecycle', () => { /no matching step\/start/, ) - const mismatchedEnd = new Session(SessionId('mismatched-end')) + const mismatchedEnd = Session.create(SessionId('mismatched-end')) mismatchedEnd.append('step/start', { turn: 1, step: 1 }) mismatchedEnd.append('step/end', { turn: 1, step: 2 }) expectRepeatedFailure( @@ -532,7 +532,7 @@ describe('malformed replay and listener lifecycle', () => { }, ] for (const testCase of cases) { - const session = new Session(SessionId(`bad-source-${testCase.name}`)) + const session = Session.create(SessionId(`bad-source-${testCase.name}`)) session.append('step/start', { turn: 1, step: 1 }) appendHeader(session, header('deepseek-v4-flash')) const sourceEventSeqs = testCase.appendSource(session) @@ -554,7 +554,7 @@ describe('malformed replay and listener lifecycle', () => { }) it('rejects repeated and non-earlier assistant provenance', () => { - const duplicate = new Session(SessionId('duplicate-source')) + const duplicate = Session.create(SessionId('duplicate-source')) duplicate.append('step/start', { turn: 1, step: 1 }) appendHeader(duplicate, header('deepseek-v4-flash')) const source = duplicate.append('assistant/chunk', { @@ -584,7 +584,7 @@ describe('malformed replay and listener lifecycle', () => { }) expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/) - const future = new Session(SessionId('future-source')) + const future = Session.create(SessionId('future-source')) future.append('step/start', { turn: 1, step: 1 }) appendHeader(future, header('deepseek-v4-flash')) appendUnchecked(future, { @@ -611,7 +611,7 @@ describe('malformed replay and listener lifecycle', () => { }) it('does not partially apply a malformed assistant replacement', () => { - const session = new Session(SessionId('transactional-replace')) + const session = Session.create(SessionId('transactional-replace')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'head' }], source: { kind: 'user' }, @@ -638,7 +638,7 @@ describe('malformed replay and listener lifecycle', () => { }) it('rejects corrupt replacement ranges without advancing the replay cursor', () => { - const session = new Session(SessionId('bad-replace')) + const session = Session.create(SessionId('bad-replace')) const head = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'head' }], source: { kind: 'user' }, @@ -671,7 +671,7 @@ describe('malformed replay and listener lifecycle', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }] }) activeMeter.measure(session) session.append('user/message', createUserMessage({ diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 4079916a8c..07261576eb 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -70,6 +70,26 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => { return value } +/** + * Meter one upcoming replacement the way compact-basic does: price the + * replaced span from the measurement service's own nodes and log the + * shadow-price event directly before the replace. + */ +function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { + const nodes = ctx.tokenMeter.measure(session).nodes + const startIdx = nodes.findIndex(node => node.seq === start) + const endIdx = nodes.findIndex(node => node.seq === end) + const shadowed = nodes.slice(startIdx, endIdx + 1) + session.append('compact/summary', { + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start, end }, + shadowedSeqs: shadowed.map(node => node.seq), + shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0), + provider: 'mock', + model: 'mock', + }) +} + describe('tokenUsage session projection', () => { it('serves zero buckets for an empty log', async () => { const { ctx, session } = await harness() @@ -184,6 +204,7 @@ describe('tokenUsage session projection', () => { content: [{ type: 'text', text: 'before compaction' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + appendSummaryMeter(ctx, session, before.seq, before.seq) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted' }], source: { kind: 'plugin', plugin: 'test' }, @@ -235,6 +256,34 @@ function recordContext(session: Session, model: string, contextWindow?: number): }) } +/** Append one model-visible user turn and return its surface seq. */ +function appendUser(session: Session, text: string): number { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq +} + +/** Append one finalized assistant turn carrying its provider usage. */ +function appendAssistant( + session: Session, + text: string, + usage: TokenUsage, + turn: number, + step: number, +): number { + return session.append('assistant/message', { + turn, + step, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage, + }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq +} + describe('contextPressure session projection', () => { it('serves no pressure or capacity for an empty log', async () => { const { ctx, session } = await harness() @@ -277,9 +326,13 @@ describe('contextPressure session projection', () => { startStep(session, 1, 1) recordContext(session, 'small', 64_000) usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1) - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 }) + expect(pressure(ctx, session)).toEqual({ + pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000, + }) recordContext(session, 'large', 256_000) - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 }) + expect(pressure(ctx, session)).toEqual({ + pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000, + }) }) it('removes an older capacity when the newest route advertises none', async () => { @@ -288,7 +341,7 @@ describe('contextPressure session projection', () => { recordContext(session, 'small', 64_000) usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1) recordContext(session, 'unknown') - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 }) + expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 }) }) it('pushes no change for unrelated events or a restated capacity', async () => { @@ -319,7 +372,7 @@ describe('contextPressure session projection', () => { const checkpoint = JSON.parse(JSON.stringify( ctx.sessionProjections.checkpoint(session), )) as ReturnType<typeof ctx.sessionProjections.checkpoint> - expect(checkpoint.contextPressure?.ver).toBe(2) + expect(checkpoint.contextPressure?.ver).toBe(4) await meterFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure') @@ -327,7 +380,62 @@ describe('contextPressure session projection', () => { await ctx.plugin(TokenMeterService) expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({ pressureTokens: 42, + projectedTokens: 42, contextWindow: 64_000, }) }) + + it('carries the sample forward over surface growth and a compaction', async () => { + const { ctx, session } = await harness() + recordContext(session, 'large', 128_000) + const question = appendUser(session, 'a first question worth a few tokens') + startStep(session, 1, 1) + // The provider prices the prompt its request actually carried; the sample + // must anchor against the surface as of that request, not after the + // assistant message joins it. + const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const afterTurn = pressure(ctx, session) + expect(afterTurn.pressureTokens).toBe(900) + // The assistant message landed after the sample, so it already shows. + expect(afterTurn.projectedTokens).toBeGreaterThan(900) + + const grown = appendUser(session, 'a follow-up question that grows the surface further') + const beforeCompaction = pressure(ctx, session).projectedTokens + expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!) + + // Compaction reports no usage of its own, so `pressureTokens` cannot move; + // the projected figure must shrink anyway — the defect this field fixes. + appendSummaryMeter(ctx, session, question, grown) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: grown }, + sourceEventSeqs: [question, answer, grown], + }) + const compacted = pressure(ctx, session) + expect(compacted.pressureTokens).toBe(900) + expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!) + }) + + it('clamps a projection that heuristic error drove below zero', async () => { + const { ctx, session } = await harness() + recordContext(session, 'large', 128_000) + const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4)) + startStep(session, 1, 1) + // A provider sample far below the heuristic price of what it replaced: + // shadowing that span subtracts more than the sample holds. + appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + appendSummaryMeter(ctx, session, question, question) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '.' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + expect(pressure(ctx, session).projectedTokens).toBe(0) + }) }) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 92081a860b..b8b32e36fa 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../compact/compact" + }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/lsp/README.i18n.yaml b/packages/lsp/README.i18n.yaml index d27247d06d..a41b05f148 100644 --- a/packages/lsp/README.i18n.yaml +++ b/packages/lsp/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/lsp/README.md -README.md: 7b5d9e0f50e733113539cf1ea1ed72e8651ad94c -README.zh.md: 0d068b2a78a11faa2d138d9c46d0b6bd705f8ea4 +README.md: 4964d78f1096d1a4bc78fa80c6b5febaf24a5661 +README.zh.md: 93b872002cf1f53cdbb96ff42402bfeb9557575f diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 7b5d9e0f50..4964d78f10 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -6,10 +6,8 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | Package | Role | ctx key | |---|---|---| -| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | -| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | -| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | +| [`lsp/`](lsp/README.md) | LSP provider seam and shared vocabulary | `ctx.lsp` | +| [`lsp-local/`](lsp-local/README.md) | Local stdio language-server backend | registers providers on `ctx.lsp` | +| [`tool-lsp/`](tool-lsp/README.md) | Model-facing semantic-navigation tool | registers on `ctx.tools` | -The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. - -See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. +Providers register semantic capabilities; the tool owns the model-facing contract. The child READMEs document operation, protocol, and presentation details, while the [LSP capability-seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) owns the rationale. diff --git a/packages/lsp/README.zh.md b/packages/lsp/README.zh.md index 0d068b2a78..93b872002c 100644 --- a/packages/lsp/README.zh.md +++ b/packages/lsp/README.zh.md @@ -2,14 +2,12 @@ [English](README.md) | 中文 -语言服务器能力 seam:抽象 LSP 接口、通用 stdio 提供方,以及面向模型的 `lsp` 工具。这些全是**产品**包。 +语言服务器能力 seam:抽象 LSP 接口、通用 stdio 提供方和面向模型的 `lsp` 工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| -| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError`) | `ctx.lsp` | -| `lsp-local/` | 通用多服务器本地后端(spawn、JSON-RPC、查询时临时打开文档) | (在 `ctx.lsp` 上注册提供方) | -| `tool-lsp/` | 面向模型的 `lsp` 工具(四种操作、从 1 开始的 UTF-16 光标坐标) | (注册到 `ctx.tools`) | +| [`lsp/`](lsp/README.md) | LSP 提供方 seam 和共享词汇 | `ctx.lsp` | +| [`lsp-local/`](lsp-local/README.md) | 本地 stdio 语言服务器后端 | 在 `ctx.lsp` 上注册提供方 | +| [`tool-lsp/`](tool-lsp/README.md) | 面向模型的语义导航工具 | 注册到 `ctx.tools` | -接口位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型契约。提供方注册的是**能力**而非工具;`tool-lsp` 是面向模型名称、schema、提示词指引和呈现的唯一 owner。 - -设计原理见 [LSP 能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、本地主机为何通过 Node API 而非 `ctx.fs` 读取,以及扩展名归属为何在同一运行时内互斥。 +提供方注册语义能力;工具负责面向模型的契约。子 README 记录操作、协议和呈现细节,[LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)负责设计原理。 diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index ffc3bf1d27..b786ec1a60 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-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/lsp/lsp-local/README.md -README.md: 40515ad173dec8cfe6a0937525612b7f18d22fb8 -README.zh.md: f8b17957dbd0d239aa2c0b0aeadf6136427788aa +README.md: 37676a82fb5d45b40ca86507259aca9509d25a43 +README.zh.md: 9e8b7f4f4395985bdbc29c1d911520b3559d7e0c diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 40515ad173..37676a82fb 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -54,5 +54,5 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes. ## Known Limitations and Deferred Work - **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. -- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. +- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; compatibility with one TypeScript server does not imply cross-language support. - **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index f8b17957db..9e8b7f4f43 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -53,6 +53,6 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) ## 已知限制与暂缓事项 -- **仅限可信主机本地环境**:没有沙箱隔离,也没有私有 cache/temp 写入契约;支持不受信任 binary 或受限/远程/虚拟 Workspace,需要后续的进程/文件系统契约及不同提供方(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。限制逻辑先解析 `realpath`,再通过一个带 `O_NOFOLLOW | O_NONBLOCK` 的 handle 打开源文件(最终组件符号链接防护,并以非阻塞方式拒绝 FIFO),同时进行有界读取;并发修改方如果在解析与打开之间把*祖先*目录替换为符号链接,会造成残余 TOCTOU。在该可信部署模型下接受此风险,不使用不可移植的 `openat` 逐 segment 遍历来封闭。 -- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺。 +- **仅限可信主机本地环境**:没有沙箱隔离,也没有私有 cache/temp 写入契约;支持不受信任 binary 或受限/远程/虚拟 Workspace,需要后续的进程/文件系统契约及不同提供方(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。限制逻辑先解析 `realpath`,再通过一个带 `O_NOFOLLOW | O_NONBLOCK` 的 handle 打开源文件(最终组件符号链接防护,并以非阻塞方式拒绝 FIFO),同时进行有界读取;并发修改方如果在解析与打开之间把*祖先*目录替换为符号链接,会造成残余 TOCTOU。在该可信部署模型下接受此风险,不使用不可移植的 `openat` 逐 segment 遍历来封闭。 +- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;与一个 TypeScript 服务器兼容,并不表示支持其他语言。 - **逐服务器/Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent(智能体)会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到 dispose。 diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 1d6499d9f8..b68745d617 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml index 0f4dc26e63..3faf906868 100644 --- a/packages/lsp/lsp/README.i18n.yaml +++ b/packages/lsp/lsp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008 -README.zh.md: d9404f09396ed5fe7d8cccb13bc634c04785e64b +README.zh.md: 9757147de692684747d9965efda6329b3bbe2638 diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md index d9404f0939..9757147de6 100644 --- a/packages/lsp/lsp/README.zh.md +++ b/packages/lsp/lsp/README.zh.md @@ -4,7 +4,7 @@ **LSP 能力 seam**:抽象 `LspService`(`ctx.lsp`)定义 harness 具备哪些语义代码导航能力(转到定义、查找引用、查找实现、悬停),并通过语言服务器提供方实现,不把模型契约绑定到本地子进程。 -该包(package)是 LSP 能力中负责接口的三分之一: +该包是 LSP 能力中负责接口的三分之一: | 包 | 职责 | |---|---| @@ -39,6 +39,6 @@ ## 已知限制与暂缓事项 -- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使语言 ID 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使语言 ID 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 - **仅四种操作**:symbol 与 call hierarchy 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。 - **没有观测接口**:可用性只能通过运行 `query()` 并按抛出的 `LspError` 代码进行路由来观测;没有提供方变更事件或能力状态查询。 diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 6a96dfdf70..b4a962fb34 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index 0254413924..e2e7c7517f 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/tool-lsp/README.md README.md: 9b4130015ddf7e1cad6fa9a0e131be86f3bd4bcc -README.zh.md: 570fd353a3db4265eae9447448a7716cde2efe9a +README.zh.md: a3f59fb6e39bb6c19cc2ef06d0bc256633e75386 diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index 570fd353a3..a3f59fb6e3 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -86,5 +86,5 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu ## 已知限制与暂缓事项 -- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;不在符号上的位置可能返回空结果,因此提示词解释了该约定,但不会鼓励宽泛使用 LSP(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;不在符号上的位置可能返回空结果,因此提示词解释了该约定,但不会鼓励宽泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 - **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。 diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index febc303a48..28b5a34bdb 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/mcp/README.i18n.yaml b/packages/mcp/README.i18n.yaml index 34e534336e..3ce6f13ac3 100644 --- a/packages/mcp/README.i18n.yaml +++ b/packages/mcp/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 3bde9023368da318ec572a72e2e86a7fa2d3ad8d -README.zh.md: 13410c013e67468a17bcf7173af519c7bb239e63 +# pnpm run verify-translation-pairing --write packages/mcp/README.md +README.md: cc3440f6cb2e507277e6141393ccf39fc7022d7d +README.zh.md: 0a74f3e9016be95f76104303396dc690888d645d diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3bde902336..cc3440f6cb 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -6,4 +6,4 @@ Packages bridging the harness to the MCP ecosystem. | Package | Role | |---|---| -| `mcp-client/` | MCP client bridge: connects to external MCP servers and registers their tools on `ctx.tools` | +| [`mcp-client/`](mcp-client/README.md) | MCP client bridge that registers external server tools on `ctx.tools` | diff --git a/packages/mcp/README.zh.md b/packages/mcp/README.zh.md index 13410c013e..0a74f3e901 100644 --- a/packages/mcp/README.zh.md +++ b/packages/mcp/README.zh.md @@ -1,9 +1,9 @@ -# MCP:Model Context Protocol +# MCP — 模型上下文协议 [English](README.md) | 中文 -连接 harness 与 MCP 生态的包(package)。 +将 harness 与 MCP 生态系统桥接的包。 -| 包 | 角色 | +| 包 | 职责 | |---|---| -| `mcp-client/` | MCP 客户端桥接:连接外部 MCP 服务器,并将其工具注册到 `ctx.tools` | +| [`mcp-client/`](mcp-client/README.md) | MCP 客户端桥接,将外部服务器工具注册到 `ctx.tools` | diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 0d214de9e8..3f65271905 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/plan/README.i18n.yaml b/packages/plan/README.i18n.yaml index 39eca60b79..3ae6755be6 100644 --- a/packages/plan/README.i18n.yaml +++ b/packages/plan/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/plan/README.md -README.md: eeb58703c34acae0eb2146b87b01a56815e1362a -README.zh.md: 4afe39e0eb638257c5907ddce1532abbaf720a9b +README.md: 598974e105aaeaf3c35418aec0655de2a5ba6888 +README.zh.md: 0f54299af5d34555d86d82e83b92d185045e770f diff --git a/packages/plan/README.md b/packages/plan/README.md index eeb58703c3..598974e105 100644 --- a/packages/plan/README.md +++ b/packages/plan/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -Plan mode is one logged, per-agent collaboration state. It is a single **product** package, not a generic mode registry or a capability-seam trio. +Plan mode is logged, per-agent collaboration state rather than a generic mode registry or capability seam. | Package | Role | ctx key | |---|---|---| -| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` | +| [`plan-mode/`](plan-mode/README.md) | Owns plan-mode state, guidance, commands, and review flow | `ctx.planMode` | -The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). +The [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) decision records the family design. diff --git a/packages/plan/README.zh.md b/packages/plan/README.zh.md index 4afe39e0eb..0f54299af5 100644 --- a/packages/plan/README.zh.md +++ b/packages/plan/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -Plan mode 是一种按 agent(智能体)分开记录到日志的协作状态。它是单一**产品**包(package),而非通用模式注册表或能力 seam 三包组合。 +Plan mode 是按 agent(智能体)记录的协作状态,而不是通用模式注册表或能力 seam。 | 包 | 职责 | ctx 键 | |---|---|---| -| `plan-mode/` | `plan/mode` 词汇与折叠、在边界生效的状态、`plan:policy` 引导段、`/plan [message]` 进入命令与 `/plan off` 退出命令,以及面向模型的 `exit_plan_mode` 评审工具 | `ctx.planMode` | +| [`plan-mode/`](plan-mode/README.md) | 负责 plan mode 状态、指引、命令和评审流程 | `ctx.planMode` | -活跃状态是会话日志的纯函数,因此恢复和 fork 无需额外机制即可还原该状态。部署通过 Cordis 配置提供 plan 引导内容,而 `exit_plan_mode` 在 Plan mode 未激活时仍保持注册,以稳定请求工具目录。交互式适配器使用插件拥有的 `/plan` 命令;沙箱模式和审批策略仍是独立的强制执行设置。设计详见 [plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 +[plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)决策记录了该家族的设计。 diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index a4b6723b98..68ff459cf1 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569 -README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd +README.md: 7273fa1a9be063e208596788eb4ca4f2bd3409a4 +README.zh.md: 0878319545593948fcf04fbc3de518641e1ecfbe diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6f0a9ac477..7273fa1a9b 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -8,7 +8,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces @@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. -The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. +The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. ## Session projection @@ -94,5 +94,4 @@ Mode transitions do not change the tool catalog; plan arguments and review resul - Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. - A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. -- The `exit_plan_mode` review arc has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit. -- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate. +- Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 922b153aa1..0878319545 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -2,27 +2,27 @@ [English](README.md) | 中文 -按 agent(智能体)分开记录到日志的 plan 协作状态,提供部署拥有的引导内容、直接 `/plan [message]` 进入命令、`/plan off` 退出命令,以及经评审的 `exit_plan_mode` 退出。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行轴。 +按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。 ## 持久状态 -`plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 +`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。提示词提交、常规续行和请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个被接受的轮内 pre-step;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 -## 模型与人类界面 +## 模型与人类交互 -激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。 +激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得用户明确批准后才退出。 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。 -TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 +Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -35,7 +35,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一 plan through exit_plan_mode. ``` -`section` 必填且非空。未知键会在加载时失败。该包不接受任意具名 mode、工具过滤器、沙箱设置或批准策略。 +`section` 必填且非空。出现未知键时,插件会加载失败。该包不接受任意命名的 mode、工具过滤器、沙箱设置或批准策略。 设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 @@ -45,7 +45,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一 #### 模型所见内容 -Plan mode 激活时,模型会在提示词顺序 50 处看到部署所提供的精确 `section` 文本;未激活 mode 不贡献文本。 +Plan mode 激活时,模型会在提示词顺序 50 处看到部署方提供的原样 `section` 文本;未激活 mode 不贡献文本。 ##### 配置示例 @@ -55,7 +55,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### Token 影响 -未激活 mode 不增加 token;已激活 mode 会在每个请求中添加已配置段。 +未激活 mode 不增加 token;mode 激活时,每个请求都会加入已配置的段落。 #### KV Cache 影响 @@ -65,34 +65,33 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除精确 `off` 参数以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个去除首尾空白的用户文本块。只有在最后一个请求头描述了 plan mode 时,已激活的 `/plan off` 选择才会贡献标准已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个已去除首尾空白的用户文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。经叙述的激活退出会添加一条短小且保留的切换通知。 +可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。退出已激活的 plan mode 时,如果记录了该转换,还会追加一条简短且会保留的切换通知。 #### KV Cache 影响 -用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;经叙述的退出通知追加在可复用请求前缀之后。 +用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;退出转换的记录通知会追加在可复用请求前缀之后。 -### 退出工具 schema 与评审交换 +### 退出工具 schema 与评审交互 #### 模型所见内容 -[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 +[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 #### Token 影响 -稳定 schema 的成本取决于 ToolRegistry mode,每个 plan 参数与评审结果都保留在对话历史中。 +稳定 schema 的成本取决于 ToolRegistry mode,每次传入的 plan 参数和评审结果都会保留在对话历史中。 #### KV Cache 影响 -Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。 +mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 - Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 - 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 -- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换)。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖,TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 -- 只有 Web UI 渲染 `plan-review` 意图;TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。 +- 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。 diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 6507ea4a0c..a810981357 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index b2a5f6076d..75b8ffd36b 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -8,9 +8,10 @@ * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn request boundary because every - * session event is turn-enclosed. The service flushes at `agent/step` before - * the affected request assembly, including retry turns. + * are held as pending intent until an in-turn step boundary. The service + * projects pending intent into the proposed step assembly, then flushes it + * from `agent/pre-step` only when the step is accepted. Same-step request + * retries reuse their assembly. * * The exit tool remains registered while plan mode is inactive so crossing a * boundary changes only the prompt section, not the request tool catalog. @@ -24,9 +25,9 @@ import { Context, Service } from 'cordis' import { z as zod } from 'zod' import type { ZodType } from 'zod' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' @@ -196,30 +197,40 @@ export class PlanModeService extends Service { super(ctx, 'planMode') this.section = resolveConfig(config).section let disposed = false - - // The boundary flush uses the loop's `agent/step` interception seam, not - // post-commit `session/event` observation. `agent/step` runs inside the - // open turn before every request derivation (including turn 1 step 1), so - // it is the sole flush point: prompt admission happens pre-turn, where a - // `plan/mode` append would land outside any open turn. Failures are - // contained so policy cannot block a turn; a failed append remains - // pending for a later boundary. - ctx.on('agent/step', (agent) => { - if (disposed) return + // Pre-step is outside Session.append publication, so its log-only mode + // event can land between turns or inside an open turn without re-entering + // the session. A failed append remains pending for a later boundary, and + // policy cannot block the step. + ctx.on('agent/pre-step', async ( + agent, + _messages, + { signal }, + next, + ): Promise<PreStepDecision> => { + const decision = await next() + const pending = this.pendingIntents.get(agent.session) + if (decision.kind === 'reject' || signal.aborted || pending === undefined) return decision + const narration = this.narration(agent.session, pending.active) try { - this.onBoundary(agent) + this.onBoundary(agent.session) } catch (error) { ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) + return decision } - }, { prepend: true }) - ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime') + return !pending.narrate || narration === undefined + ? decision + : { ...decision, messages: [...decision.messages, narration] } + }) + ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime') ctx.systemPrompt.section({ name: 'plan:policy', order: 50, - text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events) - ? this.section - : '', + text: (context) => { + if (context.agent === undefined) return '' + const pending = this.pendingIntents.get(context.agent.session) + return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : '' + }, }) // The plan projection unit (session-projection RFC): a pure double-event @@ -424,13 +435,13 @@ export class PlanModeService extends Service { } session.append('plan/mode', { active }) this.pendingIntents.delete(session) - this.narrate(session, active) + const narration = this.narration(session, active) + if (narration !== undefined) agent.inject(narration) return 'committed' } /** Flush one pending selection before the next request assembly. */ - private onBoundary(agent: Agent): void { - const session = agent.session + private onBoundary(session: Session): void { const pending = this.pendingIntents.get(session) if (pending === undefined) return const target = pending.active @@ -442,20 +453,20 @@ export class PlanModeService extends Service { // Delete only after append succeeds so a later boundary can retry a failed // durable write. this.pendingIntents.delete(session) - if (pending.narrate) this.narrate(session, target) } - /** Tell the model about a user switch when the last logged header described the other mode. */ - private narrate(session: Session, target: boolean): void { + /** Build a user-switch notice when the last logged header described the other mode. */ + private narration(session: Session, target: boolean): UserMessage | undefined { const told = planModeAtLastHeader(session.events) if (told === undefined || told === target) return const text = target ? 'The user switched this session to plan mode.' : 'The user switched this session back to the default mode.' - session.append('user/message', createUserMessage({ + return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'plugin', plugin: 'plan-mode' }, - }), { surfaceOp: 'append' }) + // The narration is already one sentence, so it is its own summary. + source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text }, + }) } } diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 0945a4c8c2..6e614a36a0 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' } /** * Full-loop integration: a scripted mock model drives the REAL plan-mode plugin - * through the agent loop — the pending-intent flush at the request boundary, the + * through the agent loop — the pending-intent flush at the step boundary, the * assembly the soft layer shapes (the exit tool + mode section), and the * `request/header` snapshots every transition leaves. * Only the model is mocked; the loop, the session log, and the plugin are @@ -71,8 +71,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' }) - // Selected while idle: the pending intent flushes at the first - // in-turn agent/step seam, before the first assembly. + // Selected while idle: the mode commits immediately, before the first assembly. ctx.planMode.set(agent, true) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) @@ -128,17 +127,19 @@ describe('plan mode through the agent loop', () => { expect(second.data.header.system).toContain('plan mode') }) - it('a mode flip at error settlement shapes the retry before its assembly', async () => { + it('a mode flip at error settlement waits until the step after a same-step retry', async () => { const failedRequest = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } }, }] satisfies StreamChunk[] - const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')]) + const adapter = new MockAdapter([ + failedRequest, + textResponse('Recovered with the original step assembly.'), + textResponse('Entered plan mode on the next step.'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async ( - subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, _signal, next, - ) => { + ctx.on('agent/request-error', async (subject, _context, _signal, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } @@ -150,16 +151,26 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(2) expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section) - expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section) + expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section) expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) + expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) + expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + + const nextIdle = waitForIdle(ctx, agent) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } })) + await nextIdle + + expect(adapter.requests).toHaveLength(3) + expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section) + expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) const log = agent.session.events const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' && event.data.turn === 1 && event.data.step === 1) - const retryStart = log.find(event => event.type === 'step/start' + const nextStart = log.find(event => event.type === 'step/start' && event.data.turn === 2 && event.data.step === 1) expect(firstEnd?.seq).toBeLessThan(planMode.seq) - expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0) + expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0) expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section) const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notice?.type === 'user/message' && notice.data.content).toEqual([ diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index fe31510be7..7ebee4cdb7 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -19,26 +19,25 @@ function event(active: unknown): SessionEvent { function emitTurnStart(ctx: Context, session: Session): void { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) } describe('plan-mode stream invariants', () => { it('accepts either boolean state', async () => { const ctx = await setup() - const session = new Session(SessionId('plan-state')) + const session = Session.create(SessionId('plan-state')) emitTurnStart(ctx, session) expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow() expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow() ctx.emit('session/event', session, { - type: 'turn/end', seq: 3, time: 3, - data: { turn: 1, reason: { kind: 'completed' } }, + type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) }) it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => { const ctx = await setup() - const session = new Session(SessionId(`invalid-${String(active)}`)) + const session = Session.create(SessionId(`invalid-${String(active)}`)) emitTurnStart(ctx, session) expect(() => { ctx.emit('session/event', session, event(active)) }) .toThrow(/expected a boolean/) @@ -52,11 +51,11 @@ describe('plan-mode stream invariants', () => { it('ignores unrelated dispatches and session events', async () => { const ctx = await setup() - const session = new Session(SessionId('unrelated')) + const session = Session.create(SessionId('unrelated')) expect(() => { ctx.emit('tools/change') ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) }).not.toThrow() }) @@ -65,7 +64,7 @@ describe('plan-mode stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('plan/mode', { active: 'plan' as unknown as boolean }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) @@ -77,7 +76,7 @@ describe('plan-mode stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('plan/mode', { active: true }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 6fa165cbb0..87a295e90c 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserInteractionService, { @@ -21,15 +21,22 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and * `ToolRegistry` services, with fake Agents carrying real `Session`s and a * real scoped `agent.ctx` minted through `createScope`. - * Request boundaries are simulated by dispatching the real prompt-admission - * and between-step seams used by the loop. + * Request boundaries are simulated by dispatching the real pre-step waterfall + * and the following `step/start` session event used by the loop. */ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> { // A live store session when a store is mounted (the command executor logs // lifecycle events through it); bare otherwise (fold/tool-only benches). - const session = new Session(SessionId(id)) - const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session } + const session = Session.create(SessionId(id)) + const agent = { + id: SessionId(id), + session, + options: {}, + inject(message: UserMessage) { + session.append('user/message', message, { surfaceOp: 'append' }) + }, + } as unknown as Agent & { session: Session } let scoped!: Context await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, { inject: ['tools'], @@ -56,28 +63,35 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> { } /** - * Dispatch either prompt admission or the between-step checkpoint. + * Dispatch pre-step processing and optionally its following step-start commit. */ -async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> { +async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'pre-step' | 'step-start'): Promise<void> { const events = agentEvents(ctx, agent) - if (type === 'turn/start') { - await events.waterfall( - 'agent/prompt-submit', - createUserMessage({ - content: [{ type: 'text', text: 'boundary probe' }], - source: { kind: 'user' }, - }), - new AbortController().signal, - () => Promise.resolve({ kind: 'allow' }), - ) - return + const message = createUserMessage({ + content: [{ type: 'text', text: 'boundary probe' }], + source: { kind: 'user' }, + }) + const signal = new AbortController().signal + const decision = await events.waterfall( + 'agent/pre-step', + [message], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages.slice(1)) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } + if (type === 'step-start') { + const event = agent.session.append('step/start', { turn: 1, step: 1 }) + ctx.emit('session/event', agent.session, event) } - await events.serial('agent/step', 1, 2, new AbortController().signal) } /** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */ function openTurn(session: Session, turn = 0): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } /** Close the open turn (the between-turns shape: selections commit immediately). */ @@ -153,7 +167,7 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { - const session = new Session(SessionId('fold')) + const session = Session.create(SessionId('fold')) expect(foldPlanMode(session.events)).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) @@ -162,7 +176,7 @@ describe('foldPlanMode', () => { }) it('folds a prefix when `end` is given', () => { - const session = new Session(SessionId('fold-prefix')) + const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) expect(foldPlanMode(session.events, 1)).toBe(true) @@ -209,7 +223,7 @@ describe('ctx.planMode: get/set', () => { expect(ctx.planMode.set(agent, false)).toBe('committed') expect(foldPlanMode(agent.session.events)).toBe(false) // A later boundary finds nothing pending — no double append. - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2) }) @@ -235,23 +249,26 @@ describe('ctx.planMode: get/set', () => { }) describe('the boundary flush', () => { - it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => { + it('is inert when no selection is pending', async () => { + const ctx = await setup() + const agent = await agentWithSession(ctx) + const service = ctx.planMode as unknown as { onBoundary(session: Session): void } + + expect(() => { service.onBoundary(agent.session) }).not.toThrow() + expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + }) + + it('flushes from pre-step before the following step/start', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) openTurn(agent.session) ctx.planMode.set(agent, true) - // Prompt admission runs before any turn opens; a plan/mode appended there - // would sit outside the turn. The pending intent survives admission and - // the in-turn agent/step boundary flushes it before the request derives. - await boundary(ctx, agent, 'turn/start') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) - expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'pre-step') expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) - it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => { + it('removes the pre-step flush when the plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -259,32 +276,8 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) openTurn(agent.session) ctx.planMode.set(agent, true) - // A listener captured in the same dispatch snapshot keeps the plan-mode - // callback alive across the unload; the resumed wrapper must not append - // through the disposed service. Registered prepended AFTER the plugin so - // it runs before plan-mode's own prepended flush. - ctx.on('agent/step', async () => { - await fiber.dispose() - }, { prepend: true }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) - }) - - it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG) - const agent = await agentWithSession(ctx) - openTurn(agent.session) - ctx.planMode.set(agent, true) - // Serial dispatch captures its listener list up front; prepending after - // the plugin puts this listener ahead of the plugin's own prepended one, - // so the plugin's captured callback still runs after the disposal below. - ctx.on('agent/step', async () => { - await fiber.dispose() - }, { prepend: true }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + await fiber.dispose() + await boundary(ctx, agent, 'pre-step') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) }) @@ -292,7 +285,7 @@ describe('the boundary flush', () => { const ctx = await setup() const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) }) @@ -303,7 +296,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) - await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, 'pre-step') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -312,7 +305,7 @@ describe('the boundary flush', () => { const ctx = await setup() const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, 'pre-step') expect(noticeTexts(agent.session)).toEqual([]) }) @@ -321,9 +314,9 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) header(agent.session) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) }) @@ -333,7 +326,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: true }) header(agent.session) ctx.planMode.set(agent, false) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.']) }) @@ -344,7 +337,7 @@ describe('the boundary flush', () => { header(agent.session) agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -364,19 +357,19 @@ describe('the boundary flush', () => { if (type === 'plan/mode') throw new Error('backend gone') return (original as (...args: unknown[]) => unknown)(type, ...rest) }) as unknown) as typeof agent.session.append - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(warn).toHaveBeenCalledOnce() // The failed flush re-parks the intent (cleared only after a landed // append), so the next healthy boundary converges the log with the // picker's optimistic state instead of dropping the switch forever. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) - it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => { + it('contains a pre-step append failure and keeps the intent pending', async () => { const ctx = await setup() const warn = vi.fn() ctx.logger.warn = warn as never @@ -388,9 +381,7 @@ describe('the boundary flush', () => { if (type === 'plan/mode') throw new Error('backend gone') return (original as (...args: unknown[]) => unknown)(type, ...rest) }) as unknown) as typeof agent.session.append - await boundary(ctx, agent, 'turn/start') - expect(warn).not.toHaveBeenCalled() - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'pre-step') expect(warn).toHaveBeenCalledOnce() expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) }) @@ -612,7 +603,7 @@ describe('/plan', () => { .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' }) expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false }) expect(enteringSteer).not.toHaveBeenCalled() - await boundary(ctx, entering, 'step/end') + await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) @@ -626,7 +617,7 @@ describe('/plan', () => { expect((await ctx.commands.execute(active, '/plan off', signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(activeSteer).not.toHaveBeenCalled() - await boundary(ctx, active, 'step/end') + await boundary(ctx, active, 'step-start') expect(ctx.planMode.get(active)).toEqual({ active: false }) }) @@ -751,7 +742,7 @@ describe('exit_plan_mode', () => { // step's end, so the plan policy covers any remaining call of the SAME batch. expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) @@ -808,18 +799,18 @@ describe('exit_plan_mode', () => { expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) }) - it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => { + it('an approved exit projects the next assembly before the boundary and never removes the tool', async () => { const { ctx, agent } = await setupWithReview({ selected: ['Approve'] }) const approved = await callExit(ctx, agent) expect(approved.isError).toBe(false) - // Calls of the SAME assistant response (no boundary between) were - // requested under the plan-shaped header — the fold stays plan for that - // whole batch; the boundary flush is what flips the next step. + // Calls of the SAME assistant response were requested under the existing + // plan-shaped header. Pending state shapes only the proposed next + // assembly; the accepted boundary then commits the matching durable fold. expect(foldPlanMode(agent.session.events)).toBe(true) const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) - expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION) - await boundary(ctx, agent, 'step/end') + expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) @@ -830,7 +821,7 @@ describe('exit_plan_mode', () => { const { ctx, agent } = await setupWithReview({ selected: ['Approve'] }) header(agent.session) await callExit(ctx, agent) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -1023,7 +1014,7 @@ describe('HMR disposal', () => { expect(ctx.get('planMode')).toBeUndefined() expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy') - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) }) }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 7c69417e58..c26112c8fd 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -58,7 +58,7 @@ function runPlanCommand(session: Session, args: string, index: number): void { /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ function commitPlanMode(session: Session, active: boolean, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('plan/mode', { active }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml index 19e03c982b..083a144b55 100644 --- a/packages/pty/README.i18n.yaml +++ b/packages/pty/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/README.md -README.md: e54dcf64db083665f37b7dc19a7a92e21494442b -README.zh.md: 0bb7e565a5799002ffb8b74dce12e129a7b2665e +README.md: 9c8206464d45b1be1d6ee3861c57c128e77686c5 +README.zh.md: 70d081e60a7db61443ed616b64586a93c119a640 diff --git a/packages/pty/README.md b/packages/pty/README.md index e54dcf64db..9c8206464d 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -`PTY` stands for **Pseudo-Terminal**(伪终端). This capability provides persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts. +This family provides persistent, owner-scoped pseudo-terminal sessions for interactive or stateful terminal work. It complements one-shot bash execution. | Package | Role | ctx key | |---|---|---| -| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` | -| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` | -| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` | -| `tool-bash-persistent` (`@deepseek-ai/dsh-tool-bash-persistent`) | One model-facing `bash` backed by an owner-scoped reusable PTY shell | consumes `ctx.pty`, registers on `ctx.tools` | +| [`pty/`](pty/README.md) | Defines the PTY service and session lifecycle | `ctx.pty` | +| [`pty-local/`](pty-local/README.md) | Provides local persistent terminal sessions | registers on `ctx.pty` | +| [`tool-pty/`](tool-pty/README.md) | Exposes PTY session operations to the model | registers on `ctx.tools` | +| [`tool-bash-persistent/`](tool-bash-persistent/README.md) | Exposes a reusable PTY-backed bash tool | registers on `ctx.tools` | -The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md). +The [persistent PTY decision](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) records the family boundary. diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md index 0bb7e565a5..70d081e60a 100644 --- a/packages/pty/README.zh.md +++ b/packages/pty/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -`PTY` 的全称是 **Pseudo-Terminal**(伪终端)。这项能力提供持久且限定所有者范围的终端会话,适用于需要跨工具调用保留状态或使用交互式 stdin 的工作流。PTY 是单次 bash 与文件系统工具的补充,不会取代后两者更严格的逐操作契约。 +本家族为交互式或有状态的终端工作提供持久且限定所有者范围的伪终端会话,是单次 bash 执行的补充。 | 包 | 职责 | ctx 键 | |---|---|---| -| [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确到 agent(智能体)的所有权、会话操作与等待清理完成的机制 | `ctx.pty` | -| `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` | -| `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,以及用于后台发送的通用任务集成 | 注册到 `ctx.tools` | -| `tool-bash-persistent`(`@deepseek-ai/dsh-tool-bash-persistent`) | 一个由所有者隔离可复用 PTY shell 支撑的模型可见 `bash` | 消费 `ctx.pty`,注册到 `ctx.tools` | +| [`pty/`](pty/README.md) | 定义 PTY 服务和会话生命周期 | `ctx.pty` | +| [`pty-local/`](pty-local/README.md) | 提供本地持久终端会话 | 注册到 `ctx.pty` | +| [`tool-pty/`](tool-pty/README.md) | 向模型公开 PTY 会话操作 | 注册到 `ctx.tools` | +| [`tool-bash-persistent/`](tool-bash-persistent/README.md) | 公开可复用的 PTY 后端 bash 工具 | 注册到 `ctx.tools` | -设计与暂缓边界记录在[持久 PTY Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 +[持久 PTY 决策](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md)记录了该家族的边界。 diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index e86024516e..a859f482fa 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -21,12 +21,11 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "scripts/ensure-spawn-helper.mjs", + "lib/types/**/*.d.ts" ], "scripts": { - "postinstall": "node src/ensure-spawn-helper.mjs" + "postinstall": "node scripts/ensure-spawn-helper.mjs" }, "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/pty-local/src/ensure-spawn-helper.mjs b/packages/pty/pty-local/scripts/ensure-spawn-helper.mjs similarity index 100% rename from packages/pty/pty-local/src/ensure-spawn-helper.mjs rename to packages/pty/pty-local/scripts/ensure-spawn-helper.mjs diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d9d8aa16d1..1ad29d7d01 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -3,7 +3,7 @@ import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -16,7 +16,7 @@ import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' class EmptySandbox extends SandboxProvider { confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } @@ -25,7 +25,7 @@ class RecordingSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } @@ -40,12 +40,15 @@ function config(): ResolvedConfig { function agent(ctx: Context, cwd?: string): Agent { const id = SessionId('agent') + const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }) return { - id, - options: {}, - session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }), - status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } } @@ -241,7 +244,7 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('unowned-mode')) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) }).not.toThrow() expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() }) @@ -257,8 +260,13 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: ownerFiber.ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -267,7 +275,7 @@ describe('pty-local plugin shape', () => { const unrelated = ctx.sessions.create(SessionId('unrelated-mode')) expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow() expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) }).not.toThrow() expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow() @@ -300,8 +308,13 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('pending-mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: ownerFiber.ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers<undefined>() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6fa7804980..c57b0ebb8e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import type { PtySendOperation } from '@deepseek-ai/dsh-pty' @@ -26,16 +26,22 @@ class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scope = ctx.plugin(() => {}) + const session = Session.create(id) return { - id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/README.i18n.yaml b/packages/pty/pty/README.i18n.yaml index 97b38bacd6..c7e214563d 100644 --- a/packages/pty/pty/README.i18n.yaml +++ b/packages/pty/pty/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/pty/README.md README.md: 0f8b8e499dc81ce91e249f44bb38c8cc1af89d3f -README.zh.md: 95c6345a8077d072cd22e421d3cfbbc9bf2e89b6 +README.zh.md: b0c2e21a2bd5fca1088908bdde76ff1eb2ee8bfd diff --git a/packages/pty/pty/README.zh.md b/packages/pty/pty/README.zh.md index 95c6345a80..b0c2e21a2b 100644 --- a/packages/pty/pty/README.zh.md +++ b/packages/pty/pty/README.zh.md @@ -25,7 +25,7 @@ #### 模型看到的内容 -没有直接可见内容。此包(package)不注册提示词或工具;可见 schema 和结果文本由 `@deepseek-ai/dsh-tool-pty` 负责。 +没有直接可见内容。此包不注册提示词或工具;可见 schema 和结果文本由 `@deepseek-ai/dsh-tool-pty` 负责。 #### Token 影响 diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index c9038f2ce9..702426a2b7 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 0b91d5a88c..0301de837a 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService, { PtyBackendCleanupError, PtyError, PtySessionId } from '@deepseek-ai/dsh-pty' import type { @@ -21,20 +21,20 @@ const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>() function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) + const session = Session.create(id) const agent: Agent = { id, options: {}, - session: new Session(id), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: scopeFiber.ctx, + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 5022026733..b5cede3920 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b1bc368ab2..16c4a7fea5 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import * as PtyLocal from '@deepseek-ai/dsh-pty-local' @@ -31,27 +31,27 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } function agent(ctx: Context, cwd: string): Agent { const id = SessionId('persistent-bash-loader-agent') const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) const value: Agent = { id, options: {}, - session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: scope.ctx, + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } ctx.agents.register(value) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 10f2b369a2..7b789cbd1f 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import type { @@ -29,25 +29,25 @@ afterEach(async () => { function agent(ctx: Context, cwd: string | undefined): Agent { const id = SessionId(`persistent-bash-owner-${callNumber}`) const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) const value: Agent = { id, options: {}, - session: new Session(id, [], { - version: 0, - id, - createdAt: 0, - ...cwd === undefined ? {} : { cwd }, - }), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, ctx: scope.ctx, + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } ctx.agents.register(value) diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index d8b2564736..5512daba92 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d6b4a08968..835df86abc 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -31,16 +31,22 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } function agent(ctx: Context): Agent { const scope = ctx.plugin(() => {}) const id = SessionId('pty-loader-agent') + const session = Session.create(id) const value: Agent = { - id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index a25f97ccd0..235aad0e02 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -16,9 +16,15 @@ import * as ToolPty from '@deepseek-ai/dsh-tool-pty' function fakeAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) const id = SessionId(rawId) + const session = Session.create(id) const agent: Agent = { - id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/sandbox/README.i18n.yaml b/packages/sandbox/README.i18n.yaml index 29c5f4e7a6..c851d420ae 100644 --- a/packages/sandbox/README.i18n.yaml +++ b/packages/sandbox/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/sandbox/README.md -README.md: 4a651f262ee817edf5e9620d1cbf911b2d51b90b -README.zh.md: d782d1b6391d3057efbe4ee742d86b751ebd5967 +README.md: 1ba56d22330c26302c9283b2319d983ebf902219 +README.zh.md: 5daf830854297fe14c6560dbcde8a380ad079a0e diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 4a651f262e..1ba56d2233 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -2,14 +2,12 @@ English | [中文](README.zh.md) -The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; a complete `SandboxExecutionPolicy` (mode + workspace root) rides each capability call, and its confined subset becomes the provider's `SandboxPolicy`. Different sessions and consumers can therefore confine under different policies at the same instant. All **product** packages. +This family applies per-session confinement policy to process execution. It covers same-world subprocesses; isolated environments replace complete capability implementations instead of registering here. | Package | Role | ctx key | |---|---|---| -| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` | -| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) | -| `sandbox-policy/` | The policy resolver: deployment fallbacks plus each session's durable mode and immutable cwd root. Both enforcing families consume its complete per-call result, so bash and fs cannot confine to different roots | `ctx.sandboxPolicy` | +| [`sandbox/`](sandbox/README.md) | Defines the process-sandbox service and shared escalation vocabulary | `ctx.sandbox` | +| [`sandbox-local/`](sandbox-local/README.md) | Provides local platform confinement backends | registers on `ctx.sandbox` | +| [`sandbox-policy/`](sandbox-policy/README.md) | Resolves durable per-session sandbox policy | `ctx.sandboxPolicy` | -The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). - -Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]` through `ctx.sandbox`) and [`fs/fs-sandbox`](../fs/fs-sandbox/) (an in-process path fence, not an argv wrapper — reads `ctx.sandboxPolicy` and enforces the shared mode on write/edit). The cross-family boundary is the sandbox Agent Note's [cross-family fs sandbox](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) phase; the shared vocabulary lets both families teach the model one denial marker and one escalation flow. +See the [sandbox decision](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) for the capability boundary and the [filesystem integration decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) for cross-family policy use. diff --git a/packages/sandbox/README.zh.md b/packages/sandbox/README.zh.md index d782d1b639..5daf830854 100644 --- a/packages/sandbox/README.zh.md +++ b/packages/sandbox/README.zh.md @@ -2,14 +2,12 @@ [English](README.md) | 中文 -[能力 seam 拆分](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)中负责限制的一半:抽象提供方接口、平台后端和共享策略归属位置。消费方把即将 spawn 的精确 argv 交给 `ctx.sandbox`,改为 spawn 返回的已包装 argv;完整的 `SandboxExecutionPolicy`(模式 + 工作区根目录)随每次能力调用传递,其中受限制的子集成为提供方的 `SandboxPolicy`。因此,不同会话与消费方可以同时按不同策略施加限制。这些均为**产品**包(package)。 +本家族将逐会话限制策略应用于进程执行。它覆盖与宿主共享文件系统和内核的子进程;隔离环境会替换完整的能力实现,而不是在此注册。 | 包 | 职责 | ctx key | |---|---|---| -| `sandbox/` | 抽象进程沙箱 seam(`SandboxProvider` 契约 + 模式/强制执行/策略词汇),加共享 ESCALATION 工具包(`approveEscalation`、权限逐级严格扩大的阶梯、拒绝/提示标记),以及所有强制执行方言共享的 `writableRoots` 派生 | `ctx.sandbox` | -| `sandbox-local/` | 按平台链选择的本地后端:Linux 使用 `bwrap`,否则使用 `landlock-run` launcher(通过 npm 分发的 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 家族,在自身仓库构建发布);darwin 使用 `sandbox-exec`/Seatbelt。多候选链会执行功能探测,唯一候选项直接选择,结论缓存,失败时默认拒绝 | (注册 `ctx.sandbox`) | -| `sandbox-policy/` | 策略解析器:部署回退值,加每个会话的持久模式与不可变 cwd 根目录。两个强制执行家族都消费完整的逐调用结果,因此 bash 与 fs 不会限制到不同根目录 | `ctx.sandboxPolicy` | +| [`sandbox/`](sandbox/README.md) | 定义进程沙箱服务和共享升权词汇 | `ctx.sandbox` | +| [`sandbox-local/`](sandbox-local/README.md) | 提供本地平台限制后端 | 注册到 `ctx.sandbox` | +| [`sandbox-policy/`](sandbox-policy/README.md) | 解析持久的逐会话沙箱策略 | `ctx.sandboxPolicy` | -该 seam 只限制与宿主共享文件系统和内核的子进程。容器、microVM 和远程执行器都不是这里的后端:它们会以环境一致的分组替换整个能力实现(`ctx.bash`、`ctx.fs`);边界记录在[沙箱 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)中。 - -当前消费方:[`bash/bash-sandbox`](../bash/bash-sandbox/)(包装 `['bash', '-c', command]` 时会调用 `ctx.sandbox`)和 [`fs/fs-sandbox`](../fs/fs-sandbox/)(进程内路径隔离,而非 argv 包装层;读取 `ctx.sandboxPolicy`,对写入/编辑强制执行共享模式)。跨家族边界是沙箱 Agent Note 的[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)阶段;共享词汇使两个家族可以向模型传授同一种拒绝标记与升权流程。 +[沙箱决策](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)记录了能力边界,[文件系统集成决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了跨家族策略的使用方式。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 1bcf308a49..43fb941975 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3 -README.zh.md: 5c02b0ce3fac1e39a16f8fc32517856996c30e23 +README.md: f6a1cc2b3e454e0670a564151d41182ec515bdcf +README.zh.md: 18b66af350932fc8d5c4f184d0e7fa049f910250 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 923d983be8..f6a1cc2b3e 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -4,18 +4,16 @@ English | [中文](README.zh.md) Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. -The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal. +The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal. -Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. -Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. +Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. -Each rung has a self-skipping keyless world-effect test; CI runs platform legs against real kernels and rejects a silent all-skip. The packed-install test exercises the registry launcher and executable mode through a plain-Node consumer. - ```yaml - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' @@ -37,4 +35,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. -- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 5c02b0ce3f..18b66af350 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -4,18 +4,16 @@ [`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。 -包(package)根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。 +包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。 -不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 -策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形式 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会被拒绝。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 -每个权限层级都有会自行跳过的无密钥 world-effect 测试;CI 在真实内核上运行平台 job,并拒绝所有测试静默跳过。打包安装测试通过纯 Node 消费方运行注册表 launcher 与可执行模式。 - ```yaml - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' @@ -37,4 +35,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 -- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 6750b92577..2683ab3654 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 98dc86d23e..64e92d9bf2 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,18 +1,23 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr dialects. Missing or unusable + * reports each wrap's enforcement and stderr classification facts. Missing or unusable * confinement fails closed rather than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run' +import { + LAUNCHER_BIN, + LAUNCHER_FAILURE_EXIT, + launcherPath as landlockLauncherPath, + probe as defaultProbeLandlock, +} from 'node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -20,17 +25,18 @@ export interface Config { /** * Override the runner argv; bwrap-shaped profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and - * probing; a broken runner then fails at execution and must be identifiable by - * {@link runnerFailureSignatures}. + * probing. A runner that starts but refuses its profile must be identifiable by + * {@link runnerFailureSignatures}. Consumers classify spawn rejection; only + * attributable `ENOENT` or `EACCES` with runner argv[0] provenance becomes an + * infrastructure failure. */ runnerCommand?: string[] /** * Case-insensitive stderr substrings emitted when a configured * {@link runnerCommand} refuses its profile before executing the wrapped * command. Required and non-empty with `runnerCommand`; rejected without - * it. Missing/unexecutable runner errors are added automatically from - * `runnerCommand[0]`, while these signatures cover an executable runner's - * own failure dialect. + * it. Each entry is a non-empty, single-line, case-insensitive substring + * covering the executable runner's own failure dialect. */ runnerFailureSignatures?: string[] /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ @@ -142,15 +148,22 @@ const DENIAL_SIGNATURES = { } as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]> /** - * Runner-owned stderr prefixes cover both internal refusal and shell-level - * not-found errors. Consumers match these before denial text because the - * command never ran on this path. + * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus + * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit + * 1 but its public contract does not reserve that status, while sandbox-exec + * publishes no launcher-failure status; those backends remain signature-only. + * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ -const RUNNER_FAILURE_SIGNATURES = { - bwrap: ['bwrap: '], - landlock: [`${LAUNCHER_BIN}: `], - seatbelt: ['sandbox-exec: '], -} as const satisfies Record<SelectedRunner['runner'], readonly string[]> +const RUNNER_FAILURE_RULES = { + bwrap: [{ fatalSignatures: ['bwrap: '] }], + landlock: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: [`${LAUNCHER_BIN}: `], + informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], + }], + seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], +} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]> /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless @@ -187,8 +200,8 @@ export class LocalSandboxProvider extends SandboxProvider { if (runner.length > 0 && runnerFailureSignatures.length === 0) { throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry') } - if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) { - throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty') + if (runnerFailureSignatures.some(signature => signature.trim().length === 0 || /[\r\n]/u.test(signature))) { + throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings') } this.runnerCommand = runner.length > 0 ? runner : undefined this.configuredRunnerFailureSignatures = runnerFailureSignatures @@ -204,33 +217,25 @@ export class LocalSandboxProvider extends SandboxProvider { * @param argv - the exact argv the caller is about to spawn. * @param policy - the file-effect policy this execution runs under. * @returns the wrapped argv plus the selected backend's enforcement completeness, denial - * signatures, and runner-failure signatures; throws the fail-closed + * signatures, and structured runner-failure rules; throws the fail-closed * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner. */ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { if (this.runnerCommand !== undefined) { - const argv0 = this.runnerCommand[0] as string return { argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv], enforcement: 'full', denialSignatures: DENIAL_SIGNATURES.runnerCommand, - // The operator names the configured runner's own pre-exec refusal dialect; the consumer - // additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the - // missing/unexecutable outer-shell shapes ourselves. - runnerFailureSignatures: [ - ...this.configuredRunnerFailureSignatures, - `exec: ${argv0}: not found`, - `${argv0}: No such file or directory`, - `${argv0}: Permission denied`, - ], + runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }], } } const selected = this.selectRunner(policy.mode) + const runnerArgv = this.runnerArgv(selected.runner, policy) return { - argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], + argv: [...runnerArgv, '--', ...argv], enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], - runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], + runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner], } } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f7cc952498..74d4c2a8a1 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { @@ -35,7 +36,7 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') - writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 }) + writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) return launcher } @@ -111,16 +112,7 @@ describe('runnerCommand config', () => { // An operator runner's kernel mechanism is unknown: both Linux // file-denial dialects, never bare EPERM. denialSignatures: ['read-only file system', 'permission denied'], - // The runner's own dialect is unknown, but the consumer re-joins the - // wrap through an outer `bash -c 'exec …'` — a missing or - // unexecutable runner fails with the OUTER shell's argv0-scoped - // shapes, and those classify as sandbox failures like any rung. - runnerFailureSignatures: [ - 'fake-runner: profile rejected', - 'exec: fake-runner: not found', - 'fake-runner: No such file or directory', - 'fake-runner: Permission denied', - ], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: profile rejected'] }], }) expect(probeBwrap).not.toHaveBeenCalled() expect(probeLandlock).not.toHaveBeenCalled() @@ -146,11 +138,14 @@ describe('runnerCommand config', () => { ) }) - it('rejects blank configured-runner failure signatures', async () => { - await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow( - 'runnerFailureSignatures entries must be non-empty', - ) - }) + it.each([' ', 'fatal\ncontinued', 'fatal\rcontinued'])( + 'rejects an unusable configured-runner failure signature %j', + async (signature) => { + await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [signature] })).rejects.toThrow( + 'runnerFailureSignatures entries must be non-empty single-line strings', + ) + }, + ) }) describe('the platform chains', () => { @@ -163,7 +158,7 @@ describe('the platform chains', () => { argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['read-only file system'], - runnerFailureSignatures: ['bwrap: '], + runnerFailureRules: [{ fatalSignatures: ['bwrap: '] }], }) expect(probeLandlock).not.toHaveBeenCalled() }) @@ -178,14 +173,18 @@ describe('the platform chains', () => { argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['permission denied'], - runnerFailureSignatures: ['landlock-run: '], + runnerFailureRules: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }], }) expect(probeLandlock).toHaveBeenCalledWith(launcher) }) it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => { // The safety property moves to execution time: an unusable sandbox-exec - // refuses to run the command, and the wrap's runnerFailureSignatures let + // refuses to run the command, and the wrap's runnerFailureRules let // the consumer classify that as a sandbox failure, not a task failure. const probeSeatbelt = vi.fn(() => true) const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt }) @@ -194,7 +193,7 @@ describe('the platform chains', () => { argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) expect(probeSeatbelt).not.toHaveBeenCalled() }) @@ -311,7 +310,7 @@ describe('the default landlock probe (launcher CLI contract)', () => { it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') - writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 }) + writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) @@ -360,7 +359,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) }) diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 6f516c222d..6124a75ff6 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 463d5b3963..c535847f58 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -22,7 +22,7 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange function session(id: string, cwd?: string): Session { const sessionId = SessionId(id) - return new Session(sessionId, undefined, { + return Session.create(sessionId, undefined, { version: 0, id: sessionId, createdAt: 0, @@ -195,7 +195,7 @@ describe('sandbox:policy request context', () => { it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => { const active = session('sess-resume', '/projects/current') setSandboxMode(active, 'workspace-write') - const resumed = new Session(active.id, active.events, active.header) + const resumed = Session.create(active.id, active.events, active.header) const ctx = await promptMounted({ mode: 'read-only' }) expect(await policyContext(ctx, resumed)).toContain('workspace-write') @@ -209,7 +209,7 @@ describe('the sandbox/mode session kit', () => { }) it('effectiveSandboxMode folds to the last switch, or undefined without one', () => { - const session = new Session(SessionId('sess-fold')) + const session = Session.create(SessionId('sess-fold')) expect(effectiveSandboxMode(session.events)).toBeUndefined() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') @@ -217,7 +217,7 @@ describe('the sandbox/mode session kit', () => { }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { - const session = new Session(SessionId('sess-write')) + const session = Session.create(SessionId('sess-write')) setSandboxMode(session, 'danger-full-access') const modeEvents = session.events.filter(e => e.type === 'sandbox/mode') expect(modeEvents).toHaveLength(1) diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 83b28c41b8..11f2a2ed86 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/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/sandbox/sandbox/README.md -README.md: 99f0641560937f66df6db76ae55c90595329792f -README.zh.md: 5d7dfb303ea2405e725d162698c3ab3d39485636 +README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 +README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 99f0641560..1b522b2c72 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. -The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. +The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus the selected backend's enforcement completeness, denial dialect (`denialSignatures`), and structured runner-failure evidence (`runnerFailureRules`); when no backend is usable it throws rather than passing the argv through unconfined. The [core type catalog](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) owns the exact classifier shape. Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy. @@ -39,4 +39,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions. - **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here. - **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output. +- **Runner diagnostics are in-band** — exit status plus stderr evidence cannot prove which process wrote a matching line, so a confined child that deliberately mimics its runner can cause an availability/diagnostic false attribution. This cannot bypass confinement; an out-of-band runner-status channel is deferred. - **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity. diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index 5d7dfb303e..adccd4421a 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包(package):只依赖 cordis(及 harness 错误基类),绝不依赖后端。 +抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包:只依赖 cordis(及 harness 错误基类),绝不依赖后端。 -用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件操作被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是统一的跨后端签名集合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。 +用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。 策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。 -**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力实现(`ctx.bash`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力实现(`ctx.bash`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 实现:[`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)(Linux:`bwrap`,否则使用相应平台的 Landlock launcher;macOS:`sandbox-exec`/Seatbelt)。消费方:[`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/)(包装 `['bash', '-c', command]`)。 @@ -32,11 +32,12 @@ sandbox mode "<mode>" is requested but no sandbox backend is usable on this host #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 - **文件操作是完整的策略词汇**:该 seam 不表达网络、进程、系统调用、设备或凭据限制。 - **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。 - **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。 +- **Runner 诊断使用带内通道**:退出状态与 stderr 证据无法证明匹配行由哪个进程写入,因此受限子进程若故意模仿 runner,就可能造成可用性或诊断误归因。这无法绕过约束;带外 runner 状态通道暂缓实现。 - **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。 diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 84266499b3..deb703dc15 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 781227f411..aeca1ba8d6 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -62,6 +62,22 @@ export interface SandboxPolicy extends SandboxExecutionPolicy { mode: ConfinedSandboxMode } +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +export interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} + /** * A {@link SandboxProvider.confine} result: the argv to spawn in place of * the caller's own, plus the enforcement completeness the selected backend @@ -82,11 +98,12 @@ export interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } /** diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml index 0602769f1c..738a0610ab 100644 --- a/packages/sdk/README.i18n.yaml +++ b/packages/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/README.md -README.md: 0dcf3655fc4d20452981f83000f9b9b586ede17b -README.zh.md: 215e2ddc47151d8828cbfaee7eae48d517bdad47 +README.md: 3f99d6d45ddc64ac068457b0d03e533d21451a5f +README.zh.md: 003383c5f6238b37893927813959d52d96350c3e diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0dcf3655fc..3f99d6d45d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,18 +2,15 @@ English | [中文](README.zh.md) -Developer tooling for creating, editing, building, and running DeepSeek Harness projects, plus the client SDK stack for driving a harness runtime from another process. - -The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries; the [TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client SDK stack. +This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process. | Package | Role | |---|---| -| [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction | -| [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` | -| [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer | -| [`sdk-protocol`](sdk-protocol/README.md) | Shared SDK runtime wire protocol: the newline-delimited JSON-RPC transport + named request/notification types | -| [`sdk-client`](sdk-client/README.md) | TypeScript client SDK: drive a harness runtime subprocess over stdio JSON-RPC (the Python SDK's design twin) | +| [`helper/`](helper/README.md) | Provides the shared project-editing domain | +| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands | +| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects | +| [`sdk-protocol/`](sdk-protocol/README.md) | Defines the SDK runtime wire protocol | +| [`sdk-client/`](sdk-client/README.md) | Drives a Harness runtime through the TypeScript client API | +| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives | -`@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`. - -Generated projects keep `cordis.yml` as the only runtime plugin tree. `dsh-sdk dev` adds TypeScript and local-workspace resolution around that same file; it does not create a development-only config. +`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md). diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md index 215e2ddc47..003383c5f6 100644 --- a/packages/sdk/README.zh.md +++ b/packages/sdk/README.zh.md @@ -1,19 +1,16 @@ -# SDK 包(package) +# SDK 包 [English](README.md) | 中文 -用于创建、编辑、构建和运行 DeepSeek Harness 项目的开发者工具,外加从另一进程驱动 harness 运行时的客户端 SDK 栈。 - -[功能 Agent Note(agent 决策记录)](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)负责开发者工作流;[架构 Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责包与项目编辑边界;[TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端 SDK 栈。 +本分组包含 Harness 项目的开发者工具,以及从另一个进程驱动 Harness 运行时的客户端栈。 | 包 | 职责 | |---|---| -| [`helper`](helper/README.md) | 项目聚合、编辑会话、内置功能、项目文档、模板、包管理器与提示词抽象 | -| [`scripts`](scripts/README.md) | `dsh-sdk` 启动器:`start`、`dev`、`build` 和交互式 `config` | -| [`create-sdk`](create-sdk/README.md) | `npm create @deepseek-ai/sdk` 初始化器 | -| [`sdk-protocol`](sdk-protocol/README.md) | 共享的 SDK 运行时通信协议:以换行符分隔的 JSON-RPC 传输 + 具名请求/通知类型 | -| [`sdk-client`](sdk-client/README.md) | TypeScript 客户端 SDK:通过 stdio JSON-RPC 驱动 harness 运行时子进程(Python SDK 的设计孪生) | +| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 | +| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 | +| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 | +| [`sdk-protocol/`](sdk-protocol/README.md) | 定义 SDK 运行时协议格式 | +| [`sdk-client/`](sdk-client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | +| [`telemetry/`](telemetry/README.md) | 提供启动器遥测、同意和脱敏原语 | -`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外:npm 的 scoped initializer 约定要求使用该名称,才能支持 `npm create @deepseek-ai/sdk`。 - -生成的项目始终以 `cordis.yml` 作为唯一运行时插件树。`dsh-sdk dev` 只是围绕同一文件增加 TypeScript 与本地工作区解析,不会创建仅供开发环境使用的配置。 +`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其他包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)和 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。 diff --git a/packages/sdk/create-sdk/package.json b/packages/sdk/create-sdk/package.json index 27102154fc..262d375563 100644 --- a/packages/sdk/create-sdk/package.json +++ b/packages/sdk/create-sdk/package.json @@ -24,9 +24,7 @@ "lib/invariant.js", "lib/bin.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 2884e04f72..bf9eacb813 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -20,9 +20,7 @@ "lib/index.js", "lib/invariant.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 278b609dd8..b23325445d 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -34,6 +34,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry required: true, baseResources: [ { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, + { kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' }, { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, ], options: [ diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index e8b88f91f3..4c3d39c07c 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -90,7 +90,7 @@ export class ProjectEditSession implements FeatureProjectView { this.profile = source.profile this.documents = source.cloneDocuments() for (const feature of registry.all()) { - /* v8 ignore next -- no current builtin is interface-specific after TUI removal */ + /* v8 ignore next -- no current built-in feature is interface-specific */ if (!feature.isApplicable(this.profile)) continue const installation = feature.inspect(this) this.states.set(feature.id, { @@ -508,7 +508,7 @@ export class ProjectEditSession implements FeatureProjectView { const view = this.projectView(profile) for (const feature of this.registry.all()) { const state = this.states.get(feature.id) - /* v8 ignore next 5 -- no current builtin is interface-specific after TUI removal */ + /* v8 ignore next 5 -- no current built-in feature is interface-specific */ if (!feature.isApplicable(profile)) { if (state?.state === 'enabled') { throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`) diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index e4bb35491c..deef54831f 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -30,16 +30,14 @@ "lib/dev/tsdown-config.js", "lib/local-plugin-loader-hooks.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", - "node-addon-require-builtin": "^0.1.3" + "node-addon-require-builtin": "^0.1.4" }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 9c578f3297..73bb07ea02 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -135,7 +135,7 @@ export class ConfigWorkflow { runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget), } for (const feature of features) { - /* v8 ignore next -- no current builtin is interface-specific after TUI removal */ + /* v8 ignore next -- no current built-in feature is interface-specific */ if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) } diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml index 27bd88cefd..bff82b8aec 100644 --- a/packages/sdk/sdk-client/README.i18n.yaml +++ b/packages/sdk/sdk-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/sdk/sdk-client/README.md -README.md: eb0387292fb0093b3ed9360e8aec087201b611f0 -README.zh.md: 3745d2bd3aba72942a0c69b8669ea707bd429188 +README.md: f27ec256330254156c45b136c21308529ea99e3d +README.zh.md: bd23762500a4da469839da8ddb50455e3419b553 diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md index eb0387292f..f27ec25633 100644 --- a/packages/sdk/sdk-client/README.md +++ b/packages/sdk/sdk-client/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. +The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. -Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. +Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — including the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend and automation — that know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. ## DeepSeekHarness @@ -18,23 +18,21 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.status, result.finalResponse) +console.log(result.finalResponse) ``` -The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle. + +`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input. ## HarnessClient -The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). +The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). `close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse. `HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches. -## Testing - -Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: turn loop, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, the turn result, and the persisted logs; `DSH_SNAPSHOT=record` re-records against the live API. - ## Model Experience None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes. @@ -47,5 +45,5 @@ None; this package neither assembles nor sends a provider request. - **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. - **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)). -- **One in-flight prompt per session** — a server-side rule this client surfaces as a `JsonRpcResponseError`; independent sessions run concurrently on one runtime. +- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime. - **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows. diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md index 3745d2bd3a..bd23762500 100644 --- a/packages/sdk/sdk-client/README.zh.md +++ b/packages/sdk/sdk-client/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层轮次 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 +以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 -与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 +与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 ## DeepSeekHarness @@ -18,23 +18,21 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.status, result.finalResponse) +console.log(result.finalResponse) ``` -子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个提示词轮次,在配对的 `session.finished` 到达时完成,并返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按协议传输顺序排列。模型层失败会返回 `status: 'error'` 的结果,绝不会导致 Promise 被拒绝;Promise 被拒绝意味着传输丢失、超时或协议违例。 +子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。 + +`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。 ## HarnessClient -轮次 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;协议层没有取消机制,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 +自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 `close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 `HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。 -## 测试 - -免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):轮次循环、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、轮次结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。 - ## 模型体验 无,因为这是一个客户端进程库;模型运行在 spawn 出的运行时中,其体验由该运行时的 `cordis.yml` 所组合的插件决定。 @@ -47,5 +45,5 @@ console.log(result.status, result.finalResponse) - **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 - **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。 -- **每会话同时只有一个在途提示词**——服务端规则,本客户端将其呈现为 `JsonRpcResponseError`;相互独立的会话可在同一运行时上并发。 +- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。 - **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。 diff --git a/packages/sdk/sdk-client/package.json b/packages/sdk/sdk-client/package.json index 27341f2040..3dbb236ab3 100644 --- a/packages/sdk/sdk-client/package.json +++ b/packages/sdk/sdk-client/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sdk/sdk-client/src/api.ts b/packages/sdk/sdk-client/src/api.ts index 08f201cf71..6e76efa417 100644 --- a/packages/sdk/sdk-client/src/api.ts +++ b/packages/sdk/sdk-client/src/api.ts @@ -1,7 +1,7 @@ /** - * High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one + * High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one * runtime subprocess across many sessions; `HarnessSession.run` sends a - * prompt and settles with the final response once `session.finished` arrives. + * prompt and settles when the whole agent next becomes idle. * Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair. * * @module @deepseek-ai/dsh-sdk-client/api @@ -9,9 +9,9 @@ import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { HarnessClient, isRecord, SdkProtocolError } from './client.ts' -import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime @@ -93,9 +93,9 @@ export class DeepSeekHarness implements AsyncDisposable { * Run one prompt on a fresh (or named) session. * @param input - prompt text, or content blocks sent verbatim. * @param options - optional session id and per-notification observer. - * @returns the settled turn result. + * @returns the owned activity interval. */ - run(input: string | ContentBlock[], options?: RunOptions): Promise<TurnResult> { + run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> { return this.session(options?.sessionId).run(input, options) } @@ -127,8 +127,7 @@ export interface RunOptions { } /** - * One SDK session: a stable id plus the turn loop that pairs a - * `session/prompt` with its `session.finished`. + * One SDK session: a stable id plus owned activity intervals. */ export class HarnessSession { /** @@ -138,27 +137,23 @@ export class HarnessSession { constructor(readonly harness: DeepSeekHarness, readonly id: string) {} /** - * Run one prompt turn to settlement. + * Queue one prompt, then observe the whole session through its next idle. * @param input - prompt text, or content blocks sent verbatim. * @param options - optional per-notification observer. - * @returns the settled turn result; rejects on transport loss, timeout, or - * a protocol error — never on a model-level failure (that is - * `status: 'error'` in the result). + * @returns the owned activity interval; rejects on transport loss, timeout, + * or a protocol error. */ - async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<TurnResult> { + async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> { await this.harness.start() const client = this.harness.client const contentBlocks = normalizeInput(input) const events: SessionEvent[] = [] const notifications: HarnessNotification[] = [] - let status: TurnResult['status'] = 'error' - let reason: TurnEndReason | undefined - let finished = false const subscription = client.subscribeSessionTree(this.id) const collect = (notification: HarnessNotification): void => { if (notification.method === 'session.event' && notification.params.sessionId === this.id) { - // Wire boundary: the envelope feeds the typed TurnResult, so a + // Wire boundary: the envelope feeds the typed RunResult, so a // malformed runtime surfaces as a protocol error, not as type-invalid // data (or a TypeError out of finalResponse). const event = validatedSessionEvent(notification.params.event) @@ -167,37 +162,31 @@ export class HarnessSession { events.push(event) return } - if (notification.method === 'session.finished' && notification.params.sessionId === this.id) { - reason = validatedTurnEndReason(notification.params.reason) - notifications.push(notification) - options?.onNotification?.(notification) - status = notification.params.status === 'ok' ? 'ok' : 'error' - finished = true - return - } notifications.push(notification) options?.onNotification?.(notification) } - const accepted = client.prompt(this.id, contentBlocks) - // Drain concurrently so observers see progress while the prompt request - // is still pending (its response arrives only after settlement). - const drain = (async () => { - while (!finished) collect(await subscription.next()) - })() try { - await Promise.all([accepted, drain]) + const messageId = await client.prompt(this.id, contentBlocks) + let received = false + while (true) { + const notification = await subscription.next() + if (!received) { + if (notification.method !== 'session.event' + || notification.params.sessionId !== this.id + || !isInboxReceipt(notification.params.event, messageId)) continue + received = true + } + collect(notification) + if (notification.method === 'session.status' + && notification.params.sessionId === this.id + && notification.params.status === 'idle') break + } } finally { - // On a prompt rejection the drain is still parked on next(); closing the - // subscription settles it, and the swallow keeps that secondary - // TransportClosedError from surfacing as an unhandled rejection. subscription.close() - await drain.catch(() => {}) } return { sessionId: this.id, - status, - reason, finalResponse: finalResponse(events), events, notifications, @@ -232,18 +221,16 @@ function validatedSessionEvent(value: unknown): SessionEvent { return value as unknown as SessionEvent } -/** Validate a wire `session.finished` reason (absent, or a kind-tagged record). */ -function validatedTurnEndReason(value: unknown): TurnEndReason | undefined { - if (value === undefined) return undefined - if (!isRecord(value) || typeof value.kind !== 'string') { - throw new SdkProtocolError(`session.finished carried a malformed reason: ${JSON.stringify(value)}`) - } - return value as unknown as TurnEndReason +/** Whether a raw session event is the durable enqueue receipt for `messageId`. */ +function isInboxReceipt(value: unknown, messageId: string): boolean { + if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false + const inserted = value.data.inserted + return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId) } /** * Extract the concatenated text of the last assistant message. - * @param events - the turn's `session.event` payloads in wire order. + * @param events - the activity interval's `session.event` payloads in wire order. * @returns the final response text, or `''` when no assistant message exists. */ export function finalResponse(events: SessionEvent[]): string { diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index af19f26868..1937f4a1dc 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -275,17 +275,18 @@ export class HarnessClient { } /** - * Run one prompt turn to settlement (the response arrives only after the - * turn settled; progress streams as notifications meanwhile). + * Queue one prompt and return its durable inbox identity. * @param sessionId - target session; an unknown id creates it. * @param contentBlocks - the user message, sent verbatim. + * @returns the queued message id. */ - async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<void> { + async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> { const params: SessionPromptParams = { sessionId, contentBlocks } const result = await this.request('session/prompt', { ...params }) - if (!isRecord(result) || result.accepted !== true) { - throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`) + if (!isRecord(result) || typeof result.messageId !== 'string') { + throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`) } + return result.messageId } /** diff --git a/packages/sdk/sdk-client/src/index.ts b/packages/sdk/sdk-client/src/index.ts index 5fd1297a7f..128cfbb898 100644 --- a/packages/sdk/sdk-client/src/index.ts +++ b/packages/sdk/sdk-client/src/index.ts @@ -1,7 +1,7 @@ /** * TypeScript client SDK for the DeepSeek Harness runtime: spawn the * `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over - * stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API; + * stdio JSON-RPC. `DeepSeekHarness` is the high-level run API; * `HarnessClient` is the lower-level protocol client. A pure library — it * registers nothing on a Cordis context; the runtime process it spawns is a * complete harness configured by its own `cordis.yml`. @@ -25,5 +25,5 @@ export type { HarnessClientOptions, HarnessNotification, NotificationFilter, - TurnResult, + RunResult, } from './types.ts' diff --git a/packages/sdk/sdk-client/src/types.ts b/packages/sdk/sdk-client/src/types.ts index 8f983375c4..1ac92f43a3 100644 --- a/packages/sdk/sdk-client/src/types.ts +++ b/packages/sdk/sdk-client/src/types.ts @@ -1,17 +1,16 @@ /** * Types for the TypeScript SDK client: launch options, notification shapes, - * and turn results. + * and owned activity results. * * @module @deepseek-ai/dsh-sdk-client/types */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol' +import type { SessionEvent } from '@deepseek-ai/dsh-session' /** One server-to-client notification as received off the wire. */ export interface HarnessNotification { - /** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */ + /** The JSON-RPC notification method name. */ method: string /** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */ params: Record<string, unknown> @@ -59,15 +58,11 @@ export interface DeepSeekHarnessOptions { maxTokens?: number } -/** The settled outcome of one {@link HarnessSession.run} turn. */ -export interface TurnResult { - /** The session the turn ran on. */ +/** One owned session activity interval, from enqueue receipt through idle. */ +export interface RunResult { + /** The session the activity ran on. */ sessionId: string - /** Deployment-mapped turn outcome from `session.finished`. */ - status: SdkRunStatus - /** Why the last message-triggered turn ended; `undefined` when no turn ran. */ - reason: TurnEndReason | undefined - /** Concatenated text of the session's last assistant message (empty when none). */ + /** Concatenated text of the interval's last assistant message (empty when none). */ finalResponse: string /** Every `session.event` payload for the root session, in wire order. */ events: SessionEvent[] diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts index 626ca87bc6..85d5253765 100644 --- a/packages/sdk/sdk-client/tests/fake-runtime.ts +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -142,13 +142,6 @@ function runTurn(sessionId: string): void { lastAssistantMessage: [{ type: 'text', text: 'child says hi' }], }) } - notify('session.finished', { - sessionId, - status: env.FAKE_STATUS ?? 'ok', - ...(env.FAKE_MALFORMED_REASON !== undefined - ? { reason: 'not-a-record' } - : reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }), - }) } function sessionIdOf(params: Record<string, unknown> | undefined): string { @@ -197,8 +190,20 @@ reader.on('line', (line) => { respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return case 'session/prompt': { + const sessionId = sessionIdOf(frame.params) + const messageId = `fake-user-${seq}` + event(sessionId, 'agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [{ + id: messageId, + role: 'user', + content: [], + source: { kind: 'user' }, + }], + }) + notify('session.status', { sessionId, status: 'running' }) if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) { - const sessionId = sessionIdOf(frame.params) event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } }) respond({}) return @@ -208,9 +213,9 @@ reader.on('line', (line) => { respond({}) return } - const sessionId = sessionIdOf(frame.params) runTurn(sessionId) - respond({ accepted: true }) + notify('session.status', { sessionId, status: 'idle' }) + respond({ messageId }) return } case 'shutdown': diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 63093e602b..8b76af755e 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { DeepSeekHarness, HarnessClient, + HarnessSession, JsonRpcResponseError, RequestTimeoutError, SdkProtocolError, @@ -53,17 +54,74 @@ async function tempDir(prefix: string): Promise<string> { } describe('DeepSeekHarness', () => { + it('ignores notifications that precede the submitted message receipt', async () => { + const notifications = [ + { method: 'session.status', params: { sessionId: 'owned', status: 'running' } }, + { + method: 'session.event', + params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } }, + }, + { + method: 'session.event', + params: { + sessionId: 'owned', + event: { type: 'agent/inbox/spliced', data: { inserted: null } }, + }, + }, + { + method: 'session.event', + params: { + sessionId: 'owned', + event: { + type: 'agent/inbox/spliced', + seq: 0, + time: 0, + data: { + target: 'next-turn', + start: 0, + inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }], + }, + }, + }, + }, + { method: 'session.status', params: { sessionId: 'owned', status: 'idle' } }, + ] as HarnessNotification[] + let closed = false + const harness = { + start: () => Promise.resolve(), + client: { + prompt: () => Promise.resolve('accepted-message'), + subscribeSessionTree: () => ({ + next: async () => { + const notification = notifications.shift() + if (notification === undefined) throw new Error('scripted notification queue exhausted') + return notification + }, + tryNext: () => notifications.shift(), + close: () => { closed = true }, + async * [Symbol.asyncIterator]() {}, + }), + }, + } as unknown as DeepSeekHarness + + const result = await new HarnessSession(harness, 'owned').run('go') + + expect(result.notifications.map(notification => notification.method)) + .toEqual(['session.event', 'session.status']) + expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(closed).toBe(true) + }) + it('runs a turn end to end and reuses the runtime across sessions', async () => { const harness = harnessWith({ FAKE_TEXT: 'turn answer' }) const first = await harness.run('say hi') - expect(first.status).toBe('ok') - expect(first.reason).toEqual({ kind: 'completed' }) expect(first.finalResponse).toBe('turn answer') - expect(first.events.map(event => event.type)).toEqual(['turn/start', 'assistant/chunk', 'assistant/message', 'turn/end']) + expect(first.events.map(event => event.type)).toEqual([ + 'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end', + ]) // Same subprocess, second session: ids differ, protocol state is reusable. const second = await harness.run([{ type: 'text', text: 'again' }]) - expect(second.status).toBe('ok') expect(second.sessionId).not.toBe(first.sessionId) await harness.close() }) @@ -76,13 +134,12 @@ describe('DeepSeekHarness', () => { onNotification: (n) => { seen.push(n) }, }) - expect(result.status).toBe('ok') // The child session's events arrive through subagent.started lineage. expect(seen.map(n => n.method)).toContain('subagent.started') expect(seen.map(n => n.method)).toContain('subagent.finished') const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child') expect(childEvents.length).toBeGreaterThan(0) - // TurnResult.events is the root session's typed stream; descendants retain + // RunResult.events is the root session's typed stream; descendants retain // their session ids in the raw notification stream above. expect(result.events.every(event => event.type !== 'assistant/message' || event.data.message.content[0]?.type !== 'text' @@ -90,22 +147,6 @@ describe('DeepSeekHarness', () => { await harness.close() }) - it('reports an error status with the turn-end reason', async () => { - const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' }) - const result = await harness.run('overflow') - expect(result.status).toBe('error') - expect(result.reason).toEqual({ kind: 'max-tokens' }) - await harness.close() - }) - - it('omits the reason when the runtime settled without one', async () => { - const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' }) - const result = await harness.run('no turn') - expect(result.status).toBe('error') - expect(result.reason).toBeUndefined() - await harness.close() - }) - it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => { const dir = await tempDir('sdk-client-init-') const recordFile = join(dir, 'init.jsonl') @@ -176,7 +217,6 @@ describe('DeepSeekHarness', () => { // Retry spawns a NEW subprocess through a fresh client (close is permanent). const result = await harness.run('again') expect(harness.client).not.toBe(firstClient) - expect(result.status).toBe('ok') expect(result.finalResponse).toBe('second boot answer') await harness.close() // close() is terminal: a handshake failure after it must not respawn. @@ -194,7 +234,7 @@ describe('DeepSeekHarness', () => { await using harness = new DeepSeekHarness({ launch: fakeLaunch() }) captured = harness const result = await harness.run('scoped') - expect(result.status).toBe('ok') + expect(result.finalResponse).toBe('hello from fake runtime') } // After scope exit the runtime is closed: reuse fails loudly. await expect(captured.run('after')).rejects.toThrow(TransportClosedError) @@ -311,14 +351,15 @@ describe('HarnessClient', () => { await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) const all = client.subscribe() - const finishedOnly = client.subscribe(n => n.method === 'session.finished') + const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle') + const firstPending = all.next() await client.prompt('sub-test', normalizeInput('go')) - const first = await all.next() + const first = await firstPending expect(first.method).toBe('session.event') - const finished = await finishedOnly.next() - expect(finished.method).toBe('session.finished') - expect(finishedOnly.tryNext()).toBeUndefined() + const idle = await idleOnly.next() + expect(idle.method).toBe('session.status') + expect(idleOnly.tryNext()).toBeUndefined() // A bare unbounded request with omitted params sends `{}` on the wire. const identity = await client.request('initialize') as { serverInfo: { name: string } } @@ -328,12 +369,12 @@ describe('HarnessClient', () => { const collected: string[] = [] for await (const notification of all) { collected.push(notification.method) - if (notification.method === 'session.finished') break + if (notification.method === 'session.status' && notification.params.status === 'idle') break } - expect(collected.at(-1)).toBe('session.finished') + expect(collected.at(-1)).toBe('session.status') all.close() - finishedOnly.close() + idleOnly.close() await expect(all.next()).rejects.toThrow('notification subscription closed') await client.close() }) @@ -346,11 +387,11 @@ describe('HarnessClient', () => { const broken = client.subscribe(() => { throw new Error('filter exploded') }) // A non-Error throw is normalized rather than crashing dispatch. const brokenNonError = client.subscribe(() => { throw 'string boom' }) - const healthy = client.subscribe(n => n.method === 'session.finished') + const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle') await client.prompt('filter-contain', normalizeInput('go')) // The sibling subscription and the read loop are undisturbed. - expect((await healthy.next()).method).toBe('session.finished') + expect((await healthy.next()).method).toBe('session.status') // Each broken subscription failed with ITS OWN error and detached. await expect(broken.next()).rejects.toThrow('filter exploded') await expect(brokenNonError.next()).rejects.toThrow('string boom') @@ -445,10 +486,6 @@ describe('wire payload validation', () => { await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError) }) - it('rejects a malformed session.finished reason as a protocol error', async () => { - const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' }) - await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError) - }) }) describe('stderr tail bound', () => { diff --git a/packages/sdk/sdk-protocol/README.i18n.yaml b/packages/sdk/sdk-protocol/README.i18n.yaml index b868126d59..f878b9fd63 100644 --- a/packages/sdk/sdk-protocol/README.i18n.yaml +++ b/packages/sdk/sdk-protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md -README.md: 6dfc749bb0610f2c94e1a23fa395a428e47126bc -README.zh.md: 2c2284dcdac3029ffaf6cac65e4c1247a2328939 +README.md: 2d141e6f62ac0d324933996c282a2d0380af29f2 +README.zh.md: bc459f8817c9477a451015f7629168e246ee531c diff --git a/packages/sdk/sdk-protocol/README.md b/packages/sdk/sdk-protocol/README.md index 6dfc749bb0..2d141e6f62 100644 --- a/packages/sdk/sdk-protocol/README.md +++ b/packages/sdk/sdk-protocol/README.md @@ -15,14 +15,14 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim | Direction | Method | Types | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | -| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (answered only after turn settlement) | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | | client→server | `shutdown` | no params → `{}` | | server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) | -| server→client | `session.finished` | `SessionFinishedNotification` (one per accepted prompt) | +| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. +`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. ## Model Experience diff --git a/packages/sdk/sdk-protocol/README.zh.md b/packages/sdk/sdk-protocol/README.zh.md index 2c2284dcda..bc459f8817 100644 --- a/packages/sdk/sdk-protocol/README.zh.md +++ b/packages/sdk/sdk-protocol/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包(package)根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。 +DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。 ## 传输 @@ -15,14 +15,14 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 | 方向 | 方法 | 类型 | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | -| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(仅在轮次结算完成后应答) | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | | client→server | `shutdown` | 无参数 → `{}` | | server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) | -| server→client | `session.finished` | `SessionFinishedNotification`(每个获准的提示词请求一条) | +| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent(智能体)及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 +`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 ## 模型体验 diff --git a/packages/sdk/sdk-protocol/package.json b/packages/sdk/sdk-protocol/package.json index bc4591a669..fe7d057c11 100644 --- a/packages/sdk/sdk-protocol/package.json +++ b/packages/sdk/sdk-protocol/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sdk/sdk-protocol/src/index.ts b/packages/sdk/sdk-protocol/src/index.ts index c11a270f47..777290c17c 100644 --- a/packages/sdk/sdk-protocol/src/index.ts +++ b/packages/sdk/sdk-protocol/src/index.ts @@ -17,7 +17,7 @@ export type { InitializeResult, SdkRunStatus, SessionEventNotification, - SessionFinishedNotification, + SessionStatusNotification, SessionPromptParams, SessionPromptResult, SubagentFinishedNotification, diff --git a/packages/sdk/sdk-protocol/src/types.ts b/packages/sdk/sdk-protocol/src/types.ts index 1b7a372f66..dc8e11587f 100644 --- a/packages/sdk/sdk-protocol/src/types.ts +++ b/packages/sdk/sdk-protocol/src/types.ts @@ -9,7 +9,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' /** Parameters for the process-wide SDK handshake. */ @@ -38,10 +38,10 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ +/** Durable enqueue receipt for one prompt. */ export interface SessionPromptResult { - /** Always `true`; the turn outcome is the paired `session.finished` notification. */ - accepted: true + /** Identity of the queued user message. */ + messageId: string } /** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */ @@ -55,14 +55,12 @@ export interface SessionEventNotification { event: SessionEvent } -/** `session.finished` payload: one per accepted prompt, after turn settlement. */ -export interface SessionFinishedNotification { - /** The settled session. */ +/** Whole-agent lifecycle state for one session. */ +export interface SessionStatusNotification { + /** Session whose live agent changed status. */ sessionId: string - /** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */ - status: SdkRunStatus - /** Why the last message-triggered turn ended; absent when no turn ran. */ - reason: TurnEndReason | undefined + /** The whole-agent state after the transition. */ + status: 'idle' | 'running' } /** `subagent.started` payload: an in-runtime child session was created. */ @@ -94,7 +92,7 @@ export interface SubagentFinishedNotification { /** Server-to-client notifications by JSON-RPC method name. */ export interface HarnessSdkNotificationMap { 'session.event': SessionEventNotification - 'session.finished': SessionFinishedNotification + 'session.status': SessionStatusNotification 'subagent.started': SubagentStartedNotification 'subagent.finished': SubagentFinishedNotification } diff --git a/packages/sdk/sdk-protocol/tests/transport.spec.ts b/packages/sdk/sdk-protocol/tests/transport.spec.ts index a07324a6fc..c14111be22 100644 --- a/packages/sdk/sdk-protocol/tests/transport.spec.ts +++ b/packages/sdk/sdk-protocol/tests/transport.spec.ts @@ -29,11 +29,11 @@ describe('JsonRpcLineTransport', () => { const response = await b.request('echo', { value: 42 }) expect(response).toEqual({ echoed: { value: 42 } }) - a.notify('session.finished', { sessionId: 'main', status: 'ok' }) + a.notify('session.status', { sessionId: 'main', status: 'idle' }) a.notify('heartbeat') await new Promise(resolve => setTimeout(resolve, 10)) expect(notifications).toEqual([ - { method: 'session.finished', params: { sessionId: 'main', status: 'ok' } }, + { method: 'session.status', params: { sessionId: 'main', status: 'idle' } }, { method: 'heartbeat', params: {} }, ]) diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index b7bd3df0d5..9baaa2ebff 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/telemetry/README.md README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327 -README.zh.md: d71aa36be66a250056dd20a1ca9a88836b28c184 +README.zh.md: af54cc2c78cb360d4305eeaf584c8330a0b7efa5 diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index d71aa36be6..af54cc2c78 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包(package)的职责,而不是此包的职责。 +用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。 | 导出 | 职责 | |---|---| diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json index aeb75c4b1f..6fe62e35fc 100644 --- a/packages/sdk/telemetry/package.json +++ b/packages/sdk/telemetry/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-persistence/README.i18n.yaml b/packages/session-persistence/README.i18n.yaml index 43b0fcfdb6..85eb2bd27d 100644 --- a/packages/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/README.md -README.md: ac4e0a8310152b9d2ba5daae61fbbf1eb0ed54ec -README.zh.md: fe311556b3cdc518db10e513baa81d6a0002165d +README.md: 060f757b3568318b0be4b01b3a10d019d28eac3b +README.zh.md: 67022565f7dabcbbccee30d5ce60fa559088ca39 diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index ac4e0a8310..060f757b35 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -The durable session-persistence seam and its storage backends. The interface package owns the abstract `SessionPersistence` service and the shared write coordinator; the backends are concrete implementations that register on `ctx.sessionPersistence`. All **product** packages. +This family defines durable session persistence, semantic checkpoint policy, and the shipped storage backends. | Package | Role | ctx key | |---|---|---| -| `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` | -| `session-checkpoint-policy/` | Semantic durability barriers for agent requests and tool execution | (wraps `ctx.llm` / `ctx.tools`, listens on agent events) | -| `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | -| `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | +| [`session-persistence/`](session-persistence/README.md) | Defines the persistence service and shared write coordination | `ctx.sessionPersistence` | +| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | Applies semantic durability checkpoints | wraps `ctx.llm` and `ctx.tools` | +| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Persists sessions in JSONL files | registers on `ctx.sessionPersistence` | +| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Persists sessions in SQLite | registers on `ctx.sessionPersistence` | -The interface lives at `session-persistence/session-persistence/`; backends are flat siblings. A new storage backend joins here and registers on `ctx.sessionPersistence`. See [session persistence](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The [session-persistence decision](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) records the family design. diff --git a/packages/session-persistence/README.zh.md b/packages/session-persistence/README.zh.md index fe311556b3..67022565f7 100644 --- a/packages/session-persistence/README.zh.md +++ b/packages/session-persistence/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -持久会话的持久化 seam 及其存储后端。接口包(package)负责抽象 `SessionPersistence` 服务和共享写入协调器;后端是注册到 `ctx.sessionPersistence` 的具体实现。全部都是**产品**包。 +本家族定义持久会话数据的持久化机制、语义检查点策略以及随产品交付的存储后端。 | 包 | 职责 | ctx 键 | |---|---|---| -| `session-persistence/` | 持久化 seam + 共享写入协调器 | `ctx.sessionPersistence` | -| `session-checkpoint-policy/` | agent(智能体)请求和工具执行的语义持久性屏障 | (包装 `ctx.llm` / `ctx.tools`,监听 agent 事件) | -| `session-persistence-jsonl/` | JSONL 伴随文件持久化后端 | (注册到 `ctx.sessionPersistence`) | -| `session-persistence-sqlite/` | SQLite 持久化后端 | (注册到 `ctx.sessionPersistence`) | +| [`session-persistence/`](session-persistence/README.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` | +| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` | +| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` | +| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | 将会话持久化到 SQLite | 注册到 `ctx.sessionPersistence` | -接口位于 `session-persistence/session-persistence/`;后端是同级包。新存储后端归入此处,并注册到 `ctx.sessionPersistence`。详见[会话持久化](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 +[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)记录了该家族的设计。 diff --git a/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml index 2ec207d786..5e55be6a54 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml +++ b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-checkpoint-policy/README.md -README.md: aba0f00a2960eca3db06dda267cc954f602c1b09 -README.zh.md: ae403302f86b9fadc964c7963d3a454dd14180d3 +README.md: 57be9e78236775c39971c925f4972398c0b20a97 +README.zh.md: 66508382783c36c41be65afc758398d406c3a58f diff --git a/packages/session-persistence/session-checkpoint-policy/README.md b/packages/session-persistence/session-checkpoint-policy/README.md index aba0f00a29..57be9e7823 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.md +++ b/packages/session-persistence/session-checkpoint-policy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/step` boundary so the preceding response and ordered tool results are durable before the next request. +Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/pre-step` boundary so the preceding response and ordered tool results are durable before the next request. ## Plugin (namespace: `session-checkpoint-policy`) @@ -18,7 +18,7 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools` Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend eagerly writes `session/event` appends and makes each requested `session/flush` an observation barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose the latest eagerly buffered events. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy. -The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/step` persists the preceding response/result batch before request derivation. +The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/pre-step` persists the preceding response/result batch before request derivation. Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A step-boundary rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers. diff --git a/packages/session-persistence/session-checkpoint-policy/README.zh.md b/packages/session-persistence/session-checkpoint-policy/README.zh.md index ae403302f8..6650838278 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.zh.md +++ b/packages/session-persistence/session-checkpoint-policy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。 +已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/pre-step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。 ## 插件(命名空间:`session-checkpoint-policy`) @@ -18,7 +18,7 @@ 持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会主动写入追加的 `session/event`,并把每个已请求 `session/flush` 变成观测屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失最新的已缓冲事件。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。 -策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/step` 在派生请求前持久化前一响应/结果批次。 +策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/pre-step` 在派生请求前持久化前一响应/结果批次。 在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。步骤边界处的检查点被拒绝会在另一个请求开始前使轮次失败。并发工具检查点共享会话存储的串行持久化排空流程,不会产生重复的序列号。 diff --git a/packages/session-persistence/session-checkpoint-policy/package.json b/packages/session-persistence/session-checkpoint-policy/package.json index 5d0fe5f465..afbc62345e 100644 --- a/packages/session-persistence/session-checkpoint-policy/package.json +++ b/packages/session-persistence/session-checkpoint-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index cf5722ff29..c26a65e8ad 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-agent' +import type { PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' /** Cordis plugin name used by Loader diagnostics. */ @@ -76,7 +76,8 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/step', async (agent): Promise<void> => { + ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise<PreStepDecision> => { await ctx.sessions.flush(agent.session) + return next() }) } diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index b911633316..b64ce563b9 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -69,7 +69,7 @@ async function load(root: string): Promise<SessionEvent[]> { await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) try { - return (await ctx.sessionPersistence.load(sessionId)).events + return [...(await ctx.sessionPersistence.load(sessionId)).events] } finally { await ctx.fiber.dispose() } @@ -85,7 +85,8 @@ describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash re expect(crashed.markerText).toBe('request-dispatched') const events = await load(crashed.root) expect(events.map(event => event.type)).toEqual([ - 'turn/start', 'user/message', 'step/start', 'request/header', 'request/context', 'step/end', 'turn/end', + 'agent/inbox/spliced', 'turn/start', 'agent/inbox/spliced', + 'step/start', 'user/message', 'request/header', 'request/context', 'step/end', 'turn/end', ]) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'interrupted' } }, diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 4d128c0012..b619871156 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -61,7 +61,7 @@ describe('session-checkpoint-policy request boundary', () => { it('awaits the live session checkpoint before constructing the downstream model stream', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('request-checkpoint')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const gate = Promise.withResolvers<undefined>() const order: string[] = [] ctx.on('session/flush', async () => { @@ -220,13 +220,17 @@ describe('session-checkpoint-policy tool and step boundaries', () => { expect(flushes).toBe(0) }) - it('checkpoints before the next agent step', async () => { + it('checkpoints during pre-step processing', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('post-step')) const agent = { session } as Agent const flushed: string[] = [] ctx.on('session/flush', (current) => { flushed.push(current.id) }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) expect(flushed).toEqual([session.id]) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index 924f540c8c..763b55a1a4 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-jsonl/README.md -README.md: 0f243a568a55641ded1cb6131804e77093df6af1 -README.zh.md: ae3c84e51e47edb33abbada43ec72c4d62b5a432 +README.md: cd087539bde2433fcdb70b2c511ff30880a877e1 +README.zh.md: 144b404e04f8a5fd3623d9329e4fbcafd328524d diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 0f243a568a..cd087539bd 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -26,6 +26,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence | `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. | | `packChunks` | `boolean` (default `true`) | Write eligible delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Set `false` for one-event-per-line diagnostics; reading packed rows works regardless of this write-side switch. | | `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. | +| `preparedSessionCacheSize` | positive integer (default `5`) | Maximum unpublished Sessions retained after cold history inspection for reuse by resume. | `locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix. @@ -41,9 +42,9 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. -- **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged. +- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. +- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. A full-prefix read requires the same identity before and after reading the bytes, and `readStoredRevision()` uses that identity to validate retained preparations without loading the log. Snapshot listing forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. ## Write path @@ -69,7 +70,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. -- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. +- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index ae3c84e51e..144b404e04 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -26,6 +26,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d | `root` | `string`(必需) | 所有会话文件的根目录。**无默认值**:`process.cwd()` 默认值会随进程 cwd 变更(bash 调用、子进程)而分散文件。现有根必须是可读目录;缺失根在第一次实体化时创建。 | | `packChunks` | `boolean`(默认 `true`) | 将符合条件的 delta 分片连续段写为打包行(在真实编码会话上测得逻辑日志约小 60%)。设为 `false` 可用于每事件一行诊断;无论该写入侧开关如何,都能读取打包行。 | | `compression` | `'zstd' \| 'none'` | 默认 `'zstd'`;`'none'` 保留换行分隔 UTF-8 文本。 | +| `preparedSessionCacheSize` | 正整数(默认 `5`) | 冷历史检查后保留、供恢复复用的未发布 Session 数量上限。 | `locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O:可以在目录或文件存在前返回目标,现有文件也只包含最近一次 flush 完成的前缀。 @@ -41,9 +42,9 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d - **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 - **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。 - **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷属于损坏,会被拒绝。 -- **非变更检查。**`inspect()` 返回脱离的有效前缀,不截断不完整尾部或关闭中断轮次,并保持轻量修订不变。 +- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。 - **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。 -- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。它通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 +- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 ## 写入路径 @@ -55,11 +56,11 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d #### 模型看到的内容 -JSONL 存储不影响当前提示词或 schema。加载会恢复已存储的呈现历史,并保留之前的请求 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有已持久化调用的 assistant 请求;已有已持久化调用但无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。原始 `assistant/chunk` 记录不会重复生成消息。 +JSONL 存储不影响当前提示词或 schema。加载会恢复已存储的表层历史,并保留之前的请求 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有已持久化调用的 assistant 请求;已有已持久化调用但无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。原始 `assistant/chunk` 记录不会重复生成消息。 #### Token 影响 -当前请求不会新增 token。恢复后的 agent(智能体)会因保留的历史、当前 envelope,以及每个中断调用的前述修复结果文本而消耗 token。 +当前请求不会新增 token。恢复后的 agent(智能体)会因保留的历史、当前 envelope,以及每个中断调用中以引用形式加入的修复结果文本而消耗 token。 #### KV Cache 影响 @@ -69,7 +70,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope - **只加载已配置编码和当前 `SESSION_FORMAT_VERSION` (v0)**:更改压缩需要独立/全新根,或选择遗留原始 mode;预发布格式没有迁移。 - **平铺文件存储布局不加载**:加载前使用独立根,或将预发布产物移入项目/会话目录布局。 -- **压缩文件不能直接按行读取**:使用后端加载;或在写入新根前选择 `compression: 'none'`,以便文本 fixture(测试前置数据)或外部行 reader 使用。 +- **压缩文件不能直接按行读取**:使用后端加载;或在写入新根前选择 `compression: 'none'`,以便外部行 reader 使用。 - **不删除会话文件**:日志在 `root` 下累积,直到外部移除(seam 无删除接口)。 -- **每会话一个实时 writer**:append 和修复只在所属后端实例内协调。在 owner 完全停稳 dispose 前,其他后端实例或进程不得写入同一会话;初始同 id 发布仍通过 POSIX 无覆盖硬链接或 Windows 无替换 write-through rename 保持冲突安全。 +- **每会话一个实时 writer**:append 和修复只在所属后端实例内协调。在所有者完成完全停稳的 dispose 前,其他后端实例或进程不得写入同一会话;初始同 id 发布仍通过 POSIX 无覆盖硬链接或 Windows 无替换 write-through rename 保持冲突安全。 - **POSIX 实体化需要硬链接支持**:第一次 append 使用 `link()`,使同 id 竞态失败,而不覆盖已提交日志;Windows 使用无替换 write-through rename。 diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index 91c8e81fdf..b09211669b 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index d37b5708a8..96e8221c65 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -217,106 +217,157 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean) return records.map(record => JSON.stringify(record)).join('\n') } -/** - * Parse a JSONL log buffer into its preserved event prefix (the header is line - * 0). Event lines pass through verbatim; packed chunk rows expand back into - * their events, so callers see one contiguous event list regardless of layout. - * Fully written events in an interrupted final turn remain part of the - * prefix. The first unparsable record or seq gap after the last `turn/end` - * marks a tolerated torn tail; the same hole in the committed region rejects. - * - * @param buffer - the raw bytes of the log file (header line first). - * @returns the header, the preserved event prefix, and `committedBytes` — the - * byte offset the next append truncates any torn tail to. - */ -export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { - const text = buffer.toString('utf8') - // Track complete lines by byte offset: a non-newline tail is torn and ignored, - // and a running counter avoids rescanning a long multi-byte log. - const lines: { text: string; endByte: number }[] = [] - let start = 0 - let byteOffset = 0 - for (let i = 0; i < text.length; i++) { - if (text[i] === '\n') { - const lineText = text.slice(start, i) - byteOffset += Buffer.byteLength(lineText, 'utf8') + 1 // +1 for the '\n' (a 1-byte char) - lines.push({ text: lineText, endByte: byteOffset }) - start = i + 1 - } +interface SessionLogScan { + meta: SessionHeader + events: SessionEvent[] + committedBytes: number +} + +/** Parse one complete header record supplied independently from event rows. */ +function parseHeaderRecord(record: Buffer): SessionHeader { + if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { + throw new Error('empty or header-less session log') } - - const [headerEntry, ...eventEntries] = lines - if (headerEntry === undefined) throw new Error('empty or header-less session log') - - // Line 0 is the header. - let parsedHeader: unknown + let parsed: unknown try { - parsedHeader = JSON.parse(headerEntry.text) + parsed = JSON.parse(record.subarray(0, -1).toString('utf8')) } catch { throw new Error('corrupt session log: header line is not valid JSON') } - if (!isHeaderLine(parsedHeader)) { + if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } - const headerLine = parsedHeader + return fromHeaderLine(parsed) +} - // Parse and decode every complete line first so the last valid `turn/end` - // determines whether an earlier hole interrupts an otherwise closed - // execution or belongs to a tolerable final suffix. One line yields one - // event, or a whole run for a packed chunk row; a row-tagged line that fails - // row validation is a hole, exactly like unparsable JSON. - interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number } - const parsed: Parsed[] = eventEntries.map((entry) => { - try { - return { ok: true, events: decodeStorageRecord(JSON.parse(entry.text)), endByte: entry.endByte } - } catch { - return { ok: false, endByte: entry.endByte } - } - }) +/** + * Incrementally scan complete JSONL event records after an independently + * supplied header record. Newline search and byte offsets stay on raw buffers; + * only complete records are decoded to UTF-8. A fragment crossing writes is + * copied because a decoder may reuse its output buffer after `write()` returns. + */ +export class SessionLogScanner { + private readonly meta: SessionHeader + private readonly events: SessionEvent[] = [] + private fragments: Buffer[] = [] + private fragmentBytes = 0 + private inputBytes: number + private committedBytes: number + private eventLine = 0 + private issue: Error | undefined + private finished = false - // The last index (into eventEntries) that ends in a valid `turn/end`. A hole - // before this boundary cannot be a torn final suffix because later execution - // already closed. Standalone events after it remain part of the preserved - // contiguous prefix. A packed row never stores a turn/end, so only - // single-event lines can match. - let lastTurnEnd = -1 - for (let i = parsed.length - 1; i >= 0; i--) { - const p = parsed[i] - if (p?.ok && p.events?.some(e => e.type === 'turn/end')) { lastTurnEnd = i; break } + /** + * Create an event scanner from exactly one newline-terminated header record. + * @param headerRecord - the complete first JSONL record, including its newline. + */ + constructor(headerRecord: Buffer) { + this.meta = parseHeaderRecord(headerRecord) + this.inputBytes = headerRecord.length + this.committedBytes = headerRecord.length } - // Preserve the contiguous prefix, including a complete interrupted turn; - // holes through the last committed boundary throw, while later holes stop. - // Contiguity is a cursor over seqs (not the line index): a packed row - // advances the cursor by its whole run. - const preserved: SessionEvent[] = [] - let lastPreservedLine = -1 - scan: for (let i = 0; i < parsed.length; i++) { - const p = parsed[i] - if (!p?.ok || p.events === undefined) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at line ${i + 1}`) - break // torn tail fragment after the last turn/end — stop, tolerate - } - for (const event of p.events) { - if (event.seq !== preserved.length) { - if (i <= lastTurnEnd) { - throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${preserved.length}, got ${event.seq})`) - } - break scan // gap after the last turn/end — torn tail, stop + /** + * Consume the next raw plaintext chunk, retaining only an incomplete final record. + * @param chunk - bytes immediately following all previously supplied bytes. + */ + write(chunk: Buffer): void { + if (this.finished) throw new Error('cannot write to a finished session log scanner') + const chunkStart = this.inputBytes + this.inputBytes += chunk.length + let lineStart = 0 + for ( + let newline = chunk.indexOf(0x0A); + newline !== -1; + newline = chunk.indexOf(0x0A, lineStart) + ) { + const fragment = chunk.subarray(lineStart, newline) + let line = fragment + if (this.fragments.length > 0) { + if (fragment.length > 0) this.fragments.push(fragment) + line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length) + this.fragments = [] + this.fragmentBytes = 0 } - preserved.push(event) + this.consumeEventLine(line, chunkStart + newline + 1) + lineStart = newline + 1 + } + if (lineStart < chunk.length) { + const fragment = Buffer.from(chunk.subarray(lineStart)) + this.fragments.push(fragment) + this.fragmentBytes += fragment.length } - lastPreservedLine = i } - // committedBytes = end of the last FULLY preserved line (header if none): the - // next append truncates any torn bytes past this point before writing the - // synthetic closers + new events. A line is preserved whole or not at all — - // a mid-row seq gap discards the whole row, keeping the truncation offset on - // a line boundary. - const lastPreserved = parsed[lastPreservedLine] - const committedBytes = lastPreserved !== undefined ? lastPreserved.endByte : headerEntry.endByte - return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes } + /** + * Snapshot progress before appending a recoverable torn-frame prefix. + * @returns byte, committed-prefix, and expanded-event cursors. + */ + checkpoint(): { inputBytes: number; committedBytes: number; eventCount: number } { + return { + inputBytes: this.inputBytes, + committedBytes: this.committedBytes, + eventCount: this.events.length, + } + } + + /** + * Finish scanning, ignoring a final record without a newline as a torn tail. + * @returns the header, contiguous event prefix, and safe truncation offset. + */ + finish(): SessionLogScan { + this.finished = true + return { meta: this.meta, events: this.events, committedBytes: this.committedBytes } + } + + /** Decode one complete event row and update the contiguous prefix. */ + private consumeEventLine(line: Buffer, endByte: number): void { + this.eventLine += 1 + let decoded: SessionEvent[] + try { + decoded = decodeStorageRecord(JSON.parse(line.toString('utf8'))) + } catch { + this.issue ??= new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`) + return + } + + if (this.issue !== undefined) { + if (decoded.some(event => event.type === 'turn/end')) throw this.issue + return + } + + const rowStart = this.events.length + for (const event of decoded) { + if (event.seq !== this.events.length) { + const expected = this.events.length + this.events.length = rowStart + this.issue = new Error( + `corrupt session log: seq gap in committed region at line ${this.eventLine} ` + + `(expected ${expected}, got ${event.seq})`, + ) + if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue + return + } + this.events.push(event) + } + this.committedBytes = endByte + } +} + +/** + * Parse a complete or torn JSONL buffer into its preserved event prefix. This + * compatibility wrapper supplies the first record separately, then delegates + * event rows to {@link SessionLogScanner}. + * + * @param buffer - the raw bytes of the log file (header line first). + * @returns the header, preserved event prefix, and byte offset safe to append at. + */ +export function scanLog(buffer: Buffer): SessionLogScan { + const headerEnd = buffer.indexOf(0x0A) + if (headerEnd === -1) throw new Error('empty or header-less session log') + const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1)) + scanner.write(buffer.subarray(headerEnd + 1)) + return scanner.finish() } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 4967010902..130d684208 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -11,24 +11,42 @@ import z from 'schemastery' import { readdirSync } from 'node:fs' import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine, + encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, + SessionLogScanner, toHeaderLine, type JsonlCompression, } from './format.ts' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from './zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, +} from './zstd.ts' import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' export type { JsonlCompression } from './format.ts' const DEFAULT_PACK_CHUNKS = true const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' +/** + * Internal scheduling constant, not deployment configuration: balance + * frame-boundary event-loop yields against `setImmediate` overhead. One frame + * remains an indivisible synchronous decode. + */ +const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 + +/** Assert that the independently decodable first frame contains only the header record. */ +function assertZstdHeaderFrame(plaintext: Buffer): void { + if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { + throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') + } +} /** Loader schema for the JSONL artifact's physical encoding. */ export const JsonlCompressionSchema: z<JsonlCompression> = z.union([ @@ -56,6 +74,8 @@ export interface Config { packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** Opaque coordinator token for replacing bytes recovered from a torn frame. */ @@ -64,6 +84,25 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface FileRevisionIdentity { + readonly dev: bigint + readonly ino: bigint + readonly size: bigint + readonly mtimeNs: bigint + readonly ctimeNs: bigint +} + +/** Build the source-qualified revision shared by full and lightweight reads. */ +function fileRevision(identity: FileRevisionIdentity): PersistenceRevision { + return SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -82,6 +121,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi root: z.string().required(), packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS), compression: JsonlCompressionSchema, + preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), }) /** @@ -102,10 +142,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) // Programmatic wrappers may construct the backend without Schemastery normalization. + const preparedSessionCacheSize = config.preparedSessionCacheSize + ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS this.compression = config.compression ?? DEFAULT_COMPRESSION this.assertUsableRoot() - this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this) + this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this, { + preparedSessionCacheSize, + }) } // Each backend keeps the typed service surface beside its storage hooks; @@ -126,11 +170,15 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.append(id, events) } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + override prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> { + return this.coordinator.prepare(id, signal) + } + + load(id: SessionId): Promise<SessionInspection> { return this.coordinator.load(id) } - inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> { return this.coordinator.inspect(id, signal) } @@ -156,6 +204,27 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.readPrefix(path, id, signal) } + /** + * Read one log's stat-derived revision without loading its event bytes. + * Resolving an id with unknown cwd still scans the project directories. + */ + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + try { + const identity = await stat(path, { bigint: true }) + signal?.throwIfAborted() + return fileRevision(identity) + } catch (error: unknown) { + signal?.throwIfAborted() + if (isENOENT(error)) return undefined + throw error + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -165,9 +234,20 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi expectedId?: SessionId, signal?: AbortSignal, ): Promise<StoredPrefix<JsonlTornMarker>> { - const buffer = await readFile(path, { signal }) - signal?.throwIfAborted() - let prefix: StoredPrefix<JsonlTornMarker> + let buffer: Buffer + let revision: PersistenceRevision + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) { + revision = after + break + } + } + let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'> if (this.compression === 'zstd') { prefix = await this.readZstdPrefix(buffer, signal) } else { @@ -185,74 +265,80 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) signal?.throwIfAborted() - return prefix + return { ...prefix, revision } } /** Decode complete frames and retain complete JSONL records from a torn final frame. */ private async readZstdPrefix( buffer: Buffer, signal?: AbortSignal, - ): Promise<StoredPrefix<JsonlTornMarker>> { + ): Promise<Omit<StoredPrefix<JsonlTornMarker>, 'revision'>> { signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) signal?.throwIfAborted() if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') - const plaintextFrames: Buffer[] = [] - for (const frame of frames) { - let plaintext: Buffer - try { + const decoder = createZstdFrameDecoder() + let yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS + try { + const decodedFrames = decoder.decode(buffer, frames) + signal?.throwIfAborted() + const headerFrame = decodedFrames.next() + signal?.throwIfAborted() + /* v8 ignore next -- a non-empty structural frame list makes the decoder yield its first frame or throw. */ + if (headerFrame.done) throw new Error('empty or header-less Zstandard session log') + assertZstdHeaderFrame(headerFrame.value) + const scanner = new SessionLogScanner(headerFrame.value) + + let remainingFrames = frames.length - 1 + for (const plaintext of decodedFrames) { signal?.throwIfAborted() - plaintext = await decompressZstdFrame(buffer.subarray(frame.start, frame.end)) - } catch (error) { - /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ - if (signal?.aborted) signal.throwIfAborted() - throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error }) + scanner.write(plaintext) + remainingFrames -= 1 + if (remainingFrames > 0 && performance.now() >= yieldDeadline) { + await scheduler.yield() + signal?.throwIfAborted() + yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS + } } signal?.throwIfAborted() - plaintextFrames.push(plaintext) - } + const complete = scanner.checkpoint() + if (complete.committedBytes !== complete.inputBytes) { + throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') + } + if (tornStart === undefined) { + const prefix = scanner.finish() + return { meta: prefix.meta, events: prefix.events } + } - const headerFrame = plaintextFrames[0] - if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) { - throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') - } - signal?.throwIfAborted() - const completePlaintext = Buffer.concat(plaintextFrames) - signal?.throwIfAborted() - const completePrefix = scanLog(completePlaintext) - signal?.throwIfAborted() - if (completePrefix.committedBytes !== completePlaintext.length) { - throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') - } - if (tornStart === undefined) { - return { meta: completePrefix.meta, events: completePrefix.events } - } - - let recoveredPlaintext: Buffer = Buffer.alloc(0) - try { + let recoveredPlaintext: Buffer = Buffer.alloc(0) + try { + signal?.throwIfAborted() + recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart)) + } catch { + /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ + if (signal?.aborted) signal.throwIfAborted() + // A structurally incomplete final frame may end before Node's decoder can + // emit any plaintext; the complete prior frames remain recoverable. + } signal?.throwIfAborted() - recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart)) - } catch { + scanner.write(recoveredPlaintext) + const recoveredPrefix = scanner.finish() + signal?.throwIfAborted() + return { + meta: recoveredPrefix.meta, + events: recoveredPrefix.events, + tornMarker: { + truncateTo: tornStart, + recoveredEvents: recoveredPrefix.events.slice(complete.eventCount), + }, + } + } catch (error) { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ if (signal?.aborted) signal.throwIfAborted() - // A structurally incomplete final frame may end before Node's decoder can - // emit any plaintext; the complete prior frames remain recoverable. - } - signal?.throwIfAborted() - const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext])) - signal?.throwIfAborted() - /* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */ - if (recoveredPrefix.events.length < completePrefix.events.length) { - throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames') - } - return { - meta: recoveredPrefix.meta, - events: recoveredPrefix.events, - tornMarker: { - truncateTo: tornStart, - recoveredEvents: recoveredPrefix.events.slice(completePrefix.events.length), - }, + throw error + } finally { + decoder.close() } } @@ -296,13 +382,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi signal?.throwIfAborted() snapshots.push({ header: artifact.header, - revision: SessionPersistenceRevision([ - identity.dev, - identity.ino, - identity.size, - identity.mtimeNs, - identity.ctimeNs, - ].join(':')), + revision: fileRevision(identity), }) } catch (error: unknown) { signal?.throwIfAborted() @@ -606,9 +686,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) } signal?.throwIfAborted() - if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { - throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') - } + assertZstdHeaderFrame(plaintext) return plaintext.subarray(0, -1).toString('utf8') } } finally { diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts new file mode 100644 index 0000000000..6cc88aadf7 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts @@ -0,0 +1,178 @@ +/** + * Node-private synchronous Zstandard frame decoder optimization. + * @module dsh-session-persistence-jsonl/zstd-private-decoder + */ + +import { constants as bufferConstants } from 'node:buffer' +import { createZstdDecompress } from 'node:zlib' +import type { ZstdFrameDecoder, ZstdFrameRange } from './zstd.ts' + +const DECODE_CHUNK_SIZE = 1024 * 1024 + +interface NodeZstdPrivateHandle { + writeSync( + flushFlag: number, + input: Buffer, + inputOffset: number, + inputLength: number, + output: Buffer, + outputOffset: number, + outputLength: number, + ): void +} + +type NodeZstdPrivateWriteState = Uint32Array & { 0: number; 1: number } + +interface NodeZstdPrivateState { + [key: symbol]: unknown + _handle: NodeZstdPrivateHandle | null + _writeState: NodeZstdPrivateWriteState + _defaultFlushFlag: number +} + +type NodeZstdPrivateStream = ReturnType<typeof createZstdDecompress> & NodeZstdPrivateState + +/** Return the stream with its observed private Node contract, or reject that optimization. */ +function privateZstdStream( + stream: ReturnType<typeof createZstdDecompress>, +): { stream: NodeZstdPrivateStream; errorKey: symbol } | undefined { + const candidate = stream as unknown as Partial<NodeZstdPrivateState> + const handle = candidate._handle + const errorKey = Reflect.ownKeys(stream).find((key): key is symbol => ( + typeof key === 'symbol' && key.description === 'kError' + )) + /* v8 ignore next -- one test runtime exposes one Node-private shape; the Node 22/24/26 matrix checks compatibility. */ + if ( + typeof handle !== 'object' || handle === null + || typeof (handle as { writeSync?: unknown }).writeSync !== 'function' + || !(candidate._writeState instanceof Uint32Array) + || candidate._writeState.length < 2 + || typeof candidate._defaultFlushFlag !== 'number' + || errorKey === undefined + || candidate[errorKey] !== null + ) return undefined + return { stream: stream as NodeZstdPrivateStream, errorKey } +} + +/** + * Synchronous multi-frame decoder backed by one Node Zstd stream handle. Node + * exposes synchronous decoding only as a one-shot API, so this adapter uses + * the stream's private handle contract to reuse its native context and output + * chunks across frames. + */ +export class NodePrivateZstdFrameDecoder implements ZstdFrameDecoder { + private readonly output = Buffer.allocUnsafe(DECODE_CHUNK_SIZE) + private decoderError?: Error + private started = false + private closed = false + + private constructor( + private readonly stream: NodeZstdPrivateStream, + private readonly errorKey: symbol, + ) { + this.stream.on('error', (error: Error) => { + this.decoderError ??= error + }) + } + + /** + * Create the optimized decoder when this Node release exposes the expected + * private stream shape. + * @returns a shared decoder, or `undefined` when callers must use the public fallback. + */ + static create(): NodePrivateZstdFrameDecoder | undefined { + const stream = createZstdDecompress({ chunkSize: DECODE_CHUNK_SIZE }) + const privateAccess = privateZstdStream(stream) + /* v8 ignore next -- reached only when a supported Node release changes its private stream shape. */ + if (privateAccess !== undefined) { + return new NodePrivateZstdFrameDecoder(privateAccess.stream, privateAccess.errorKey) + } + /* v8 ignore next -- the active Node runtime passed the private-shape probe above. */ + stream.close() + /* v8 ignore next -- the active Node runtime passed the private-shape probe above. */ + return undefined + } + + /** @inheritdoc */ + public *decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator<Buffer, void, void> { + if (this.started) throw new Error('Zstandard frame decoder was already started') + if (this.closed) throw new Error('cannot start a closed Zstandard frame decoder') + this.started = true + try { + for (const frame of frames) { + try { + yield this.decodeFrame(source.subarray(frame.start, frame.end)) + } catch (error) { + throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { + cause: error, + }) + } + } + } finally { + this.close() + } + } + + /** Decode one frame; its returned scratch view remains valid until the next call. */ + private decodeFrame(input: Buffer): Buffer { + const handle = this.stream._handle + /* v8 ignore next -- decode() rejects closed instances before entering this private frame operation. */ + if (this.closed || handle === null) throw new Error('cannot decode with a closed Zstandard frame decoder') + + let inputOffset = 0 + let inputRemaining = input.length + let outputBytes = 0 + const fullChunks: Buffer[] = [] + for (;;) { + handle.writeSync( + this.stream._defaultFlushFlag, + input, + inputOffset, + inputRemaining, + this.output, + 0, + this.output.length, + ) + if (this.decoderError !== undefined) throw this.decoderError + const internalError = this.stream[this.errorKey] + if (internalError !== null) { + if (internalError instanceof Error) throw internalError + throw new Error('Zstandard decoder exposed a non-Error internal failure') + } + + const outputAfter = this.stream._writeState[0] + const inputAfter = this.stream._writeState[1] + const consumed = inputRemaining - inputAfter + const produced = this.output.length - outputAfter + if (produced > 0) { + outputBytes += produced + /* v8 ignore next -- Buffer cannot materialize a frame beyond its own process-wide maximum length. */ + if (outputBytes > bufferConstants.MAX_LENGTH) { + throw new Error(`Zstandard frame output exceeds ${bufferConstants.MAX_LENGTH} bytes`) + } + } + + if (outputAfter !== 0) { + /* v8 ignore next -- structurally scanned ranges contain exactly one complete frame and no trailing bytes. */ + if (inputAfter !== 0) throw new Error('Zstandard frame decoder left trailing input') + const finalChunk = this.output.subarray(0, produced) + if (fullChunks.length === 0) return finalChunk + if (produced > 0) fullChunks.push(Buffer.from(finalChunk)) + const onlyChunk = fullChunks[0] as Buffer + return fullChunks.length === 1 + ? onlyChunk + : Buffer.concat(fullChunks, outputBytes) + } + fullChunks.push(Buffer.from(this.output)) + inputOffset += consumed + inputRemaining = inputAfter + } + } + + /** @inheritdoc */ + close(): void { + if (this.closed) return + this.closed = true + this.stream.close() + } +} diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts new file mode 100644 index 0000000000..b08c77dfdb --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts @@ -0,0 +1,40 @@ +/** + * Public-API synchronous Zstandard frame decoder fallback. + * @module dsh-session-persistence-jsonl/zstd-public-decoder + */ + +import { zstdDecompressSync } from 'node:zlib' +import type { ZstdFrameDecoder, ZstdFrameRange } from './zstd.ts' + +/** Multi-frame adapter built exclusively from Node's supported one-shot API. */ +export class PublicZstdFrameDecoder implements ZstdFrameDecoder { + private started = false + private closed = false + + /** @inheritdoc */ + public *decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator<Buffer, void, void> { + if (this.started) throw new Error('Zstandard frame decoder was already started') + if (this.closed) throw new Error('cannot start a closed Zstandard frame decoder') + this.started = true + try { + for (const { start, end } of frames) { + let decoded: Buffer + try { + decoded = zstdDecompressSync(source.subarray(start, end)) + } catch (error) { + throw new Error(`corrupt Zstandard session log: frame at byte ${start} failed validation`, { + cause: error, + }) + } + yield decoded + } + } finally { + this.close() + } + } + + /** @inheritdoc */ + close(): void { + this.closed = true + } +} diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts index e29747e399..01b4d459f0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/zstd.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts @@ -5,8 +5,12 @@ * @module dsh-session-persistence-jsonl/zstd */ -import { constants, zstdCompress, zstdDecompress, type ZstdOptions } from 'node:zlib' +import { + constants, zstdCompress, zstdDecompress, type ZstdOptions, +} from 'node:zlib' import { promisify } from 'node:util' +import { NodePrivateZstdFrameDecoder } from './zstd-private-decoder.ts' +import { PublicZstdFrameDecoder } from './zstd-public-decoder.ts' const ZSTD_MAGIC = 0xFD2FB528 const zstdCompressAsync = promisify(zstdCompress) @@ -117,6 +121,29 @@ export async function decompressZstdFrame(input: Buffer): Promise<Buffer> { return zstdDecompressAsync(input) } +/** Common lifecycle for interchangeable synchronous multi-frame decoders. */ +export interface ZstdFrameDecoder { + /** + * Decode and checksum complete frames in source order. Each yielded buffer + * remains valid only until the iterator advances to the next frame. + * @param source - concatenated Zstandard frame bytes. + * @param frames - structurally complete ranges within `source`. + * @returns one plaintext buffer per frame. + */ + decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator<Buffer, void, void> + /** Release decoder-owned resources; repeated calls are harmless. */ + close(): void +} + +/** + * Select the shared private decoder when the running Node 22/24/26 shape is + * compatible, otherwise preserve correctness with the public one-shot API. + * @returns a synchronous decoder with an implementation-independent lifecycle. + */ +export function createZstdFrameDecoder(): ZstdFrameDecoder { + return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder() +} + /** * Recover available plaintext from a structurally incomplete final frame. * `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion; diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 4eeeabc67b..03a7f90d65 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -8,11 +8,30 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine, + encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine, } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' +const statRace = vi.hoisted(() => ({ + path: undefined as string | undefined, + reads: 0, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs/promises')>() + return { + ...actual, + stat: (async (...args: Parameters<typeof actual.stat>) => { + const identity = await actual.stat(...args) + if (String(args[0]) !== statRace.path || !('mtimeNs' in identity)) return identity + statRace.reads += 1 + if (statRace.reads !== 2) return identity + return { ...identity, mtimeNs: identity.mtimeNs + 1n } + }) as typeof actual.stat, + } +}) + let root: string const dirs: string[] = [] @@ -54,12 +73,14 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin } afterEach(async () => { + statRace.path = undefined + statRace.reads = 0 vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) function appendClosedTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, @@ -208,7 +229,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { const m = meta('chunks') const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } }, { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } }, @@ -255,6 +276,57 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) + it('binds a full stored prefix to the same revision as a lightweight read', async () => { + const m = meta('stored-prefix-revision') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SessionPersistenceJsonl + + const stored = await persistence.loadStored(m.id) + expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) + expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() + }) + + it('retries a full-prefix read when the file revision changes during the read', async () => { + const m = meta('stored-prefix-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SessionPersistenceJsonl + statRace.path = rawLogPath(root, m.cwd, m.id) + + await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() }) + expect(statRace.reads).toBe(4) + }) + + it('handles revision-stat races and errors after log discovery', async () => { + const m = meta('stored-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SessionPersistenceJsonl + const internals = persistence as unknown as { + findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> + } + const path = rawLogPath(root, m.cwd, m.id) + const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path) + + await rm(path) + expect(await persistence.readStoredRevision(m.id)).toBeUndefined() + + const invalidPath = `${path}\0` + findLog.mockResolvedValue(invalidPath) + await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG_VALUE', + }) + + const reason = new Error('revision read cancelled after discovery') + const controller = new AbortController() + findLog.mockImplementation(async () => { + controller.abort(reason) + return invalidPath + }) + await expect(persistence.readStoredRevision(m.id, controller.signal)).rejects.toBe(reason) + }) + it('omits a snapshot artifact removed after discovery', async () => { const m = meta('vanishing-snapshot') await ctx.sessionPersistence.create(m) @@ -342,7 +414,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }), JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), '', @@ -399,7 +471,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // partial line with no newline (a torn fragment never fully flushed). const path = rawLogPath(root, '/proj', m.id) await writeFile(path, [ - JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }), JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }), '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline) ].join('\n'), { flag: 'a' }) @@ -418,7 +490,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // The next append continues at seq 10 (the balanced length). const turn3 = [ - { type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } }, ] as SessionEvent[] await ctx.sessionPersistence.append(m.id, turn3) @@ -437,7 +509,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' }) await ctx.sessionPersistence.load(m.id) await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8') @@ -465,7 +537,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { }) const turn2 = [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] // The append rejects, but the partial bytes are truncated back: the file is @@ -503,7 +575,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { try { await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, ] as SessionEvent[]) throw new Error('expected append to reject') } catch (error) { @@ -519,16 +591,14 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { } }) - it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { + it('load returns immutable meta without exposing backend pathing', async () => { const m = meta('meta-copy', '/proj') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) - // A consumer mutates the returned meta's cwd. The backend's stored pathing - // metadata must be unaffected, so a later append still finds the right log. - mutableHeader(loaded.meta).cwd = '/evil' + expect(() => { mutableHeader(loaded.meta).cwd = '/evil' }).toThrow() await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) // The append landed in the ORIGINAL /proj log, not beside an /evil path. @@ -545,7 +615,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) await ctx.sessionPersistence.create(b) await ctx.sessionPersistence.append(b.id, oneTurnLog()) @@ -598,8 +668,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) + b.append('turn/start', { turn: 1 }) a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'A' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -624,6 +694,65 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => describe('SessionPersistenceJsonl: scanLog unit', () => { + it('requires exactly one newline-terminated header record', () => { + const header = JSON.stringify(toHeaderLine(meta('scanner-header'))) + expect(() => new SessionLogScanner(Buffer.alloc(0))).toThrow(/header-less/) + expect(() => new SessionLogScanner(Buffer.from(header))).toThrow(/header-less/) + expect(() => new SessionLogScanner(Buffer.from(`${header}\n${header}\n`))).toThrow(/header-less/) + }) + + it('handles empty writes, boundary newlines, torn fragments, and scanner completion', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-lifecycle')))}\n`) + const event = Buffer.from(JSON.stringify(oneTurnLog()[0])) + const scanner = new SessionLogScanner(header) + + scanner.write(Buffer.alloc(0)) + scanner.write(event) + scanner.write(Buffer.from('\nignored torn tail')) + const result = scanner.finish() + + expect(result.events).toEqual([oneTurnLog()[0]]) + expect(result.committedBytes).toBe(header.length + event.length + 1) + expect(() => { scanner.write(Buffer.from('\n')) }).toThrow(/finished/) + }) + + it('keeps scanning after a tolerable corrupt suffix until a committed turn end appears', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-corrupt-suffix')))}\n`) + const scanner = new SessionLogScanner(header) + scanner.write(Buffer.from([ + JSON.stringify(oneTurnLog()[0]), + '{not json', + JSON.stringify({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }), + '', + ].join('\n'))) + expect(scanner.finish().events).toEqual([oneTurnLog()[0]]) + + const committed = new SessionLogScanner(header) + expect(() => { committed.write(Buffer.from([ + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), + '', + ].join('\n'))) }).toThrow(/seq gap in committed region/) + }) + + it('incrementally scans records split across reusable decoder chunks', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('incremental')))}\n`) + const body = Buffer.from(`${oneTurnLog().map(event => JSON.stringify(event)).join('\n').replace('"hi"', '"你好"')}\n`) + const split = body.indexOf(Buffer.from('你')) + 1 + const firstChunk = Buffer.from(body.subarray(0, split)) + const scanner = new SessionLogScanner(header) + + scanner.write(firstChunk) + const checkpoint = scanner.checkpoint() + firstChunk.fill(0) + scanner.write(body.subarray(split)) + + expect(checkpoint).toMatchObject({ + inputBytes: header.length + split, + eventCount: 1, + }) + expect(scanner.finish()).toEqual(scanLog(Buffer.concat([header, body]))) + }) + it('rejects a header-less / empty log', () => { expect(() => scanLog(Buffer.from(''))).toThrow() }) @@ -680,7 +809,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the @@ -692,7 +821,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -721,7 +850,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' // The contiguous prefix (turn/start seq 0) is preserved; the corrupt @@ -732,7 +861,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail ].join('\n') + '\n' @@ -762,7 +891,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } }, })) return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, ...deltas, { type: 'assistant/message', seq: 7, time: 8, data: { @@ -853,7 +982,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { it('scanLog: a packed row advances the seq cursor by its whole run', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -875,7 +1004,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), // seq0 skips 1 — the run's first member is already a gap; no turn/end follows. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), ].join('\n') + '\n' @@ -1153,7 +1282,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // A live session materializes and owns the id. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) @@ -1231,7 +1360,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const loaded = await ctx2.sessionPersistence.load(m.id) @@ -1246,7 +1375,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const m = meta('open-turn', '/h') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] as SessionEvent[]) const { events } = await ctx.sessionPersistence.load(m.id) expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end']) @@ -1278,7 +1407,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts index 697b95d58a..a8da30bd28 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, +} from '../src/zstd.ts' +import { NodePrivateZstdFrameDecoder } from '../src/zstd-private-decoder.ts' +import { PublicZstdFrameDecoder } from '../src/zstd-public-decoder.ts' describe('JSONL Zstandard compatibility', () => { it('round-trips concatenated checksummed frames through the built-in Node API', async () => { @@ -16,6 +20,17 @@ describe('JSONL Zstandard compatibility', () => { const decoded = await Promise.all(frames.map(frame => decompressZstdFrame(encoded.subarray(frame.start, frame.end)))) expect(Buffer.concat(decoded).toString()).toContain('"type":"turn/start"') + const preferred = createZstdFrameDecoder() + expect(preferred).toBeInstanceOf(NodePrivateZstdFrameDecoder) + for (const decoder of [preferred, new PublicZstdFrameDecoder()]) { + try { + const plaintext = Array.from(decoder.decode(encoded, frames), chunk => Buffer.from(chunk)) + expect(Buffer.concat(plaintext).toString()).toContain('"type":"turn/start"') + } finally { + decoder.close() + } + } + const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end) const missingChecksumByte = eventFrame.subarray(0, -1) expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index 0baf0ca734..b459fcac43 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -4,11 +4,17 @@ import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFil import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, + type ZstdFrameDecoder, +} from '../src/zstd.ts' +import { NodePrivateZstdFrameDecoder } from '../src/zstd-private-decoder.ts' +import { PublicZstdFrameDecoder } from '../src/zstd-public-decoder.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -17,7 +23,7 @@ const roots: string[] = [] const contexts: Context[] = [] interface ZstdReaderInternals { - readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise<unknown> + readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise<{ events: SessionEvent[] }> } type HeaderRead = ( @@ -151,6 +157,121 @@ describe('Zstandard frame structure', () => { expect(first[4]! & 0x04).toBe(0x04) expect(second[4]! & 0x04).toBe(0x04) expect((await decompressZstdFrame(first)).toString()).toBe('header\n') + const decoder = createZstdFrameDecoder() + try { + const plaintext = Array.from(decoder.decode(stream, scanZstdFrames(stream).frames), chunk => Buffer.from(chunk)) + expect(Buffer.concat(plaintext).toString()).toBe('header\nevent\n') + } finally { + decoder.close() + } + }) + + it('keeps the public and Node-private synchronous decoders interchangeable', async () => { + const frames = [await compressZstdFrame('first\n'), await compressZstdFrame('second\n')] + const stream = Buffer.concat(frames) + const ranges = scanZstdFrames(stream).frames + const privateDecoder = NodePrivateZstdFrameDecoder.create() + expect(privateDecoder).toBeDefined() + + for (const decoder of [new PublicZstdFrameDecoder(), privateDecoder!]) { + try { + const plaintext = Array.from(decoder.decode(stream, ranges), chunk => Buffer.from(chunk)) + expect(plaintext).toHaveLength(2) + expect(Buffer.concat(plaintext).toString()).toBe('first\nsecond\n') + } finally { + decoder.close() + } + } + }) + + it('falls back to the public decoder when the private Node contract is unavailable', () => { + vi.spyOn(NodePrivateZstdFrameDecoder, 'create').mockReturnValue(undefined) + const decoder = createZstdFrameDecoder() + expect(decoder).toBeInstanceOf(PublicZstdFrameDecoder) + decoder.close() + }) + + it('enforces decoder lifecycle and checksum errors through both implementations', async () => { + const frame = await compressZstdFrame('frame\n') + const range = [{ start: 0, end: frame.length }] + const corrupt = Buffer.from(frame) + corrupt[corrupt.length - 1] = corrupt[corrupt.length - 1]! ^ 0xFF + const factories: Array<() => ZstdFrameDecoder> = [ + () => new PublicZstdFrameDecoder(), + () => NodePrivateZstdFrameDecoder.create()!, + ] + + for (const create of factories) { + const interrupted = create() + const iterator = interrupted.decode(frame, range) + expect(iterator.next().value?.toString()).toBe('frame\n') + iterator.return() + expect(() => Array.from(interrupted.decode(frame, range))).toThrow(/already started/) + interrupted.close() + + const closed = create() + closed.close() + closed.close() + expect(() => Array.from(closed.decode(frame, range))).toThrow(/closed/) + + const invalid = create() + expect(() => Array.from(invalid.decode(corrupt, range))).toThrow(/frame at byte 0 failed validation/) + } + }) + + it('assembles private-decoder output at and beyond its reusable chunk boundary', async () => { + for (const length of [8, 9]) { + const plaintext = Buffer.alloc(length, 0x61) + const frame = await compressZstdFrame(plaintext) + const decoder = NodePrivateZstdFrameDecoder.create()! + ;(decoder as unknown as { output: Buffer }).output = Buffer.allocUnsafe(8) + const [decoded] = Array.from( + decoder.decode(frame, [{ start: 0, end: frame.length }]), + chunk => Buffer.from(chunk), + ) + expect(decoded).toEqual(plaintext) + } + }) + + it('normalizes private decoder stream failures', async () => { + interface PrivateDecoderInternals { + stream: { + [key: symbol]: unknown + emit(event: string, error: Error): boolean + } + errorKey: symbol + } + const frame = await compressZstdFrame('frame\n') + const range = [{ start: 0, end: frame.length }] + + const emitted = NodePrivateZstdFrameDecoder.create()! + const emittedInternals = emitted as unknown as PrivateDecoderInternals + const first = new Error('first emitted decoder failure') + emittedInternals.stream.emit('error', first) + emittedInternals.stream.emit('error', new Error('later emitted decoder failure')) + try { + Array.from(emitted.decode(frame, range)) + throw new Error('expected emitted decoder failure') + } catch (error) { + expect((error as Error).cause).toBe(first) + } + + for (const internalFailure of [new Error('internal decoder failure'), 'not an Error']) { + const decoder = NodePrivateZstdFrameDecoder.create()! + const internals = decoder as unknown as PrivateDecoderInternals + internals.stream[internals.errorKey] = internalFailure + try { + Array.from(decoder.decode(frame, range)) + throw new Error('expected internal decoder failure') + } catch (error) { + const cause = (error as Error).cause + if (internalFailure instanceof Error) { + expect(cause).toBe(internalFailure) + } else { + expect(cause).toMatchObject({ message: 'Zstandard decoder exposed a non-Error internal failure' }) + } + } + } }) it('distinguishes incomplete frame regions from invalid complete structure', () => { @@ -285,7 +406,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const path = logPath(root, header.cwd, header.id, 'zstd') const before = await readFile(path) const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] await ctx.sessionPersistence.append(header.id, secondTurn) @@ -312,7 +433,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/) }) - it('stops multi-frame inspection after cancellation interrupts the active decode', async () => { + it('stops multi-frame inspection when cancellation arrives at a slice deadline', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('cancel-zstd-frames') @@ -320,22 +441,32 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const eventFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[0])}\n`) const laterFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[1])}\n`) const stream = Buffer.concat([headerFrame, eventFrame, laterFrame]) - expect(scanZstdFrames(stream).frames).toHaveLength(3) const controller = new AbortController() const reason = new Error('cancel after Zstandard decode starts') const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals - const zstdModule = await import('../src/zstd.ts') - const decode = vi.spyOn(zstdModule, 'decompressZstdFrame') - - // readZstdPrefix reaches its first asynchronous decompression before it - // returns this promise. The microtask abort therefore occurs after decode - // starts and must prevent every later frame from reaching the decoder. + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(501) const pending = reader.readZstdPrefix(stream, controller.signal) queueMicrotask(() => { controller.abort(reason) }) await expect(pending).rejects.toBe(reason) - expect(decode).toHaveBeenCalledTimes(1) - expect(decode).toHaveBeenCalledWith(headerFrame) + }) + + it('continues decoding every frame after a slice deadline yields', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('yield-zstd-frames') + const events = oneTurnLog().slice(0, 2) + const headerFrame = await compressZstdFrame(`${JSON.stringify(toHeaderLine(header))}\n`) + const eventFrames = await Promise.all(events.map(async event => ( + compressZstdFrame(`${JSON.stringify(event)}\n`) + ))) + const stream = Buffer.concat([headerFrame, ...eventFrames]) + const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(501) + + const prefix = await reader.readZstdPrefix(stream) + + expect(prefix.events).toEqual(events) }) it.each(['none', 'zstd'] as const)( @@ -380,7 +511,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const path = logPath(root, header.cwd, header.id, 'zstd') const committed = await readFile(path) const openTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, { type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, ] as SessionEvent[] @@ -427,7 +558,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { await ctx.sessionPersistence.append(header.id, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n') @@ -475,7 +606,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { return realSync.call(this) }) const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/) diff --git a/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml b/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml index 8dea06ca1f..3dbbf87a2d 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-sqlite/README.md -README.md: 394b10a70fc757d75f19178050c0d63699a59e54 -README.zh.md: 6d2a47aa64cee59c35c558e1923a112b0d72f58b +README.md: d01ba6ebfa1f59a9e4d58f3032bbe1d016970290 +README.zh.md: c11bef5467a3401948b58d5fd8e3301b72ff3d03 diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 394b10a70f..d01ba6ebfa 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -21,8 +21,8 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. -- **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged. -- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. +- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision. +- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. ## Configuration (schemastery) @@ -30,6 +30,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' + preparedSessionCacheSize?: number // positive integer; default 5 } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/README.zh.md b/packages/session-persistence/session-persistence-sqlite/README.zh.md index 6d2a47aa64..c11bef5467 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.zh.md +++ b/packages/session-persistence/session-persistence-sqlite/README.zh.md @@ -21,8 +21,8 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[ - **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍延迟),并 INSERT 每个事件,首先断言连续 seq 契约(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。) - **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。 - **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。 -- **非变更检查。**`inspect()` 返回脱离的有效行前缀,不删除撕裂尾部行或追加恢复 closer,并保持轻量修订不变。 -- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 +- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。 +- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 ## 配置(schemastery) @@ -30,6 +30,7 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[ interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' + preparedSessionCacheSize?: number // positive integer; default 5 } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index f65bdebd16..90415bd8cc 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 8472b9836c..173d1bf857 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -14,11 +14,12 @@ import { DatabaseSync } from 'node:sqlite' import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, type StoredSuffix, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' @@ -38,6 +39,13 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } +/** Build the source-qualified revision shared by full and lightweight reads. */ +function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision { + return SessionPersistenceRevision( + `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, + ) +} + /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -73,6 +81,8 @@ export interface Config { * (network mounts). See {@link JournalMode}. */ journalMode?: JournalMode + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** @@ -86,6 +96,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers static Config: z<Config> = z.object({ path: z.string().required(), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), + preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), }) /** @@ -102,10 +113,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers constructor(ctx: Context, public config: Config) { super(ctx) + // Programmatic wrappers may construct the backend without Schemastery normalization. + const preparedSessionCacheSize = config.preparedSessionCacheSize + ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE // Open asynchronously so directory creation does not block plugin apply; // every storage hook awaits the same readiness promise. this.ready = this.openDb(config.path, (config as Required<Config>).journalMode) - this.coordinator = new PersistenceCoordinator<number>(this.ctx, this) + this.coordinator = new PersistenceCoordinator<number>(this.ctx, this, { + preparedSessionCacheSize, + }) } private async openDb(path: string, journalMode: JournalMode): Promise<void> { @@ -153,11 +169,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.append(id, events) } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + override prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> { + return this.coordinator.prepare(id, signal) + } + + load(id: SessionId): Promise<SessionInspection> { return this.coordinator.load(id) } - inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> { return this.coordinator.inspect(id, signal) } @@ -175,6 +195,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.readPrefix(id, signal) } + /** Read one row's revision without loading its events. */ + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) + } + /** * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the * read scales with the suffix, not the log. Torn rows past the preserved @@ -204,15 +233,33 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers signal?.throwIfAborted() await this.ready signal?.throwIfAborted() - const row = this.rowFor(id) - if (row === undefined) return undefined - const meta = rowToMeta(row) - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] + this.db.exec('BEGIN') + let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined + try { + const row = this.rowFor(id) + if (row !== undefined) { + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') + .all(id) as unknown as EventRow[] + snapshot = { row, eventRows } + } + this.db.exec('COMMIT') + } catch (error: unknown) { + /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */ + this.db.exec('ROLLBACK') + throw error + /* v8 ignore stop */ + } signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const { row, eventRows } = snapshot const { preserved, tornFrom } = scanRows(eventRows) - return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} } + return { + meta: rowToMeta(row), + events: preserved, + revision: sqliteRevision(this.storeIdentity, row), + ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, + } } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a2fb531143..d215696748 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -107,7 +107,7 @@ describe('scanRows', () => { // is no torn fragment to delete. (load() then synthesizes the closers.) const withOpenTurn: SessionEvent[] = [ ...oneTurnLog(), - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ] const { preserved, tornFrom } = scanRows(rows(withOpenTurn)) @@ -119,7 +119,7 @@ describe('scanRows', () => { // A gap after seq 0 (no committed turn/end): seq 0 is the preserved // interrupted-turn event; the gap bounds it and marks the torn fragment. const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing ] const { preserved, tornFrom } = scanRows(rows(gapped)) @@ -133,7 +133,7 @@ describe('scanRows', () => { it('throws on a seq gap inside the committed region (before the last turn/end)', () => { const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, ] @@ -160,6 +160,21 @@ describe('scanRows', () => { }) describe('rowToMeta', () => { + it('restores optional origin metadata', () => { + expect(rowToMeta({ + id: 'with-origin', + version: 0, + created_at: 1, + cwd: null, + parent_session: null, + seed_length: null, + origin: 'subagent', + incarnation: 'with-origin', + revision: 1, + delegation_depth: null, + })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) + }) + it('rejects fractional stored creation metadata', () => { expect(() => rowToMeta({ id: 'fractional', @@ -184,7 +199,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)') .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta') const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 })) insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } })) insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } })) db.close() @@ -229,7 +244,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await ctx1.sessionPersistence.create(m) await ctx1.sessionPersistence.append(m.id, oneTurnLog()) await ctx1.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ]) await fiber1.dispose() @@ -252,7 +267,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // load durably closed the turn, so the next append continues at the balanced // length (seq 10) and a reload round-trips identically. await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await ctx2.sessionPersistence.load(m.id) @@ -270,7 +285,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // Hand-write an interrupted turn (turn/start seq 6, no turn/end). const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') - .run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .run(m.id, 'turn/start', JSON.stringify({ turn: 2 })) db.close() const b2 = await backend(path) @@ -295,10 +310,10 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.create(m) // A first turn that NEVER completed: turn/start + user/message, no turn/end. await b1.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }) }, + }), surfaceOp: 'append' }, ]) await b1.dispose() @@ -478,7 +493,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers) // load physically deleted the corrupt tail row, so a fresh append continues. await b2.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }, ]) const reloaded = await b2.ctx.sessionPersistence.load(m.id) @@ -561,6 +576,19 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b.dispose() }) + it('binds a full stored prefix to the same revision as a lightweight read', async () => { + const b = await backend() + const m = meta('stored-prefix-revision') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = b.ctx.sessionPersistence as SessionPersistenceSqlite + + const stored = await persistence.loadStored(m.id) + expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) + expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() + await b.dispose() + }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { const path = await freshDbPath() const m = meta('recreated-revision') @@ -626,6 +654,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('resolves the preparation-cache default without schema normalization', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let persistence!: SessionPersistenceSqlite + await ctx.plugin(Object.assign((inner: Context) => { + persistence = new SessionPersistenceSqlite(inner, { + path: ':memory:', + journalMode: 'wal', + }) + }, { inject: ['sessions'] })) + + expect(await persistence.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('uses the configured preparation cache through the public service', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { + path: ':memory:', + preparedSessionCacheSize: 1, + }) + const m = meta('sqlite-preparation-cache') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + const preparation = await ctx.sessionPersistence.prepare(m.id) + expect(preparation.session.header).toEqual(m) + preparation[Symbol.dispose]() + await fiber.dispose() + }) + it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') @@ -704,7 +764,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const b2 = await backend(path) await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2 const turn2: SessionEvent[] = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] // b1 commits seq 6..7 first. @@ -762,7 +822,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await ctx.plugin(SessionPersistenceSqlite, { path }) await expectFlushError(ctx.sessions.flush(session), /id collision/) await ctx.fiber.dispose() @@ -815,7 +875,7 @@ describe('surface field round-trip', () => { await ctx.plugin(SessionStore) const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('roundtrip-surface')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, @@ -850,14 +910,11 @@ describe('surface field round-trip', () => { await ctx.plugin(SessionStore) const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('surface-noseq')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('steering/message', { - turn: 1, - message: createUserMessage({ - content: [], - source: { kind: 'user' }, - }), - }, { surfaceOp: 'append' }) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq')) diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index a4567ed1fd..7c96b1a541 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md -README.md: 1acbef838aeb8ea1b1a0e4d08b12cd00b83d85ce -README.zh.md: 74e6c62921bbcef431767a0274514e4c51f450d9 +README.md: b29ff5ba17f384e8d3b1700ed3ad6c80aeeb184c +README.zh.md: 8ca4b6a7128383fdca706f35914a06eb1f26de02 diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 1acbef838a..b29ff5ba17 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -13,9 +13,10 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | -| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. | +| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. | +| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. | | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -32,33 +33,28 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. -Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -Backend reads normalize pre-identity `user/message`, `assistant/message`, `tool/result`, and `steering/message` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise. +Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing unavailable cancellation provenance. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. -The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. +The side-effect-free `locate`, lightweight `listSnapshots`, and per-id `readStoredRevision` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage): | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; `revision` identifies exactly the returned header and events; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. | | `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). - -## Testing backends - -Import `runPersistenceContract` from `tests/contract.ts` (the public API, including stable/change-sensitive lightweight revisions) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. - -Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. +The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Metadata and location types diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index 74e6c62921..8ca4b6a712 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -抽象的持久会话持久化 seam(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 功能 seam 模板一致(见[功能 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供具体实现,消费方注入接口。 +这是用于持久保存会话的抽象 seam(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供具体实现,消费方注入接口。 -持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在并行的「持久消息」类型。不可回放的对话状态元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 +持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 ## 服务 API(`ctx.sessionPersistence`) @@ -13,52 +13,48 @@ | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | | `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | -| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | -| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀;顺序后端(JSONL)仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量,不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 | +| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复使用的精确未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | +| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 | +| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;已经实时存在的视图则是当前不可变快照,可能包含打开的 turn。基于协调器的实现会在有界 LRU 中保留精确的冷未发布 Session,供后续 `prepare` 使用,但已存储 revision 变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 | | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | -| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | +| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | ## 每个后端必须遵守的不变量 -- **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写。崩溃可留下未关闭最终轮次,其事件真实且可能很大;`load` 保留它们,并持久追加合成 closer(为每个未回答 assistant 调用添加按风险分类错误 `tool/result`,再添加 `step/end?`+`turn/end {interrupted}`),以平衡日志,并确保重新载入的历史仍是有效的提供方 transcript。只丢弃从未完整写入的撕裂尾部碎片。 +- **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写。崩溃可留下未关闭最终轮次,其事件真实且可能很大;`load` 保留它们,并持久追加合成 closer(为每个未回答 assistant 调用添加按风险分类错误 `tool/result`,再添加 `step/end?`+`turn/end {interrupted}`),以平衡日志,并确保重新载入的历史仍是有效的提供方 transcript(文本记录)。只丢弃从未完整写入的撕裂尾部碎片。 - **连续 seq。**`load` 拒绝日志中间的 `seq` 缺口/解析错误;`append` 的第一个 `seq` 必须等于已存储 next-seq。 - **JSON 可序列化数据。**`append` 通过共享单遍无损 JSON 边界实体化每个直接/回放批次。实时 `Session` 事件已深度冻结,但写入协调器仍将每个事件复制到持久化自有缓冲区。 - **持久性。**`append` 只在批次持久后返回。 ## 写入协调器 -`PersistenceCoordinator` 负责每 id 状态和串行化、每个实时会话的一个急切写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) 和 [flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)。 +`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的主动写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose(资源释放)。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) 和 [flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)。 -每个 `session/event` 将事件复制到会话 controller,并在不阻塞生产者的情况下启动急切 drain。并发通知共享当前 drain;写入期间接纳的事件保持 pending,并触发下一批。`session/flush` 是观察屏障,会等待 controller 无当前或 pending 批次。急切失败会记录日志并保留批次;下一次显式 flush 或后端拆卸重试,并向调用方公开失败。 +每个 `session/event` 将事件复制到会话 controller,并在不阻塞生产者的情况下立即启动 drain。并发通知共享当前 drain;写入期间接纳的事件保持 pending,并触发下一批。`session/flush` 是观察屏障,会等待 controller 无当前或 pending 批次。即时写入失败会记录日志并保留批次;下一次显式 flush 或后端拆卸会重试该批次,并将失败返回给调用方。 -崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源 revision 仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留精确 Session,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message`、`assistant/message`、`tool/result` 以及 steering(中途引导)对应的 `steering/message` 载荷。每条导入消息都会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load`、`inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 -无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方的精确信号传入后端发现,使观察者可在不脱离该工作的情况下取消。 +无副作用 `locate`、轻量 `listSnapshots` 和按 id 查询的 `readStoredRevision` 仍由后端负责,因为它们描述存储拓扑和 revision 身份,而非写入编排。`listSnapshots(signal?)` 将调用方传入的同一个信号传给后端发现流程,使观察者可在不脱离该工作的情况下取消。 `PersistenceBackend<TornMarker>` 钩子(协调器与存储之间的唯一 seam): | 钩子 | 职责 | |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | -| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定 revision。它使用与 `loadStored` 相同的 revision 表示;id 不存在时返回 `undefined`。 | | `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 | -| `list(signal?)` | 列出全部已存储元数据,观察可选取消。 | +| `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待。 | -协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径验证并克隆前缀,不调用 `commitRepair` 或发布写入状态。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 - -## 测试后端 - -导入 `runPersistenceContract`(公开 API,包括稳定/变更敏感的轻量修订),其来源为 `tests/contract.ts`;再导入 `runCoordinatorContract`(共享写入路径编排:接管、HMR、冲突、dispose drain、崩溃尾部修复),其来源为 `tests/coordinator-contract.ts`,并使用后端 fixture 调用两者。每个后端都遵守相同仅追加/连续 seq/延迟实体化/可序列化语义和相同编排,因此后端自身 spec 只需在其上测试存储机制(路径净化、fsync 回滚;schema 版本、事务回滚)。 - -三个后端运行这些套件:内存参考(位于 `tests/`)、`dsh-session-persistence-jsonl`(仅追加文件日志)和 `dsh-session-persistence-sqlite`(`node:sqlite`,每个 `SessionEvent` 是一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`)。它们全部通过同一契约 + 协调器套件,证明 seam 真正与后端无关:延迟实体化、load 时崩溃尾部和连续 seq 在文件字节与事务存储上表现相同。 +协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的 revision 仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,revision 重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 ## 元数据与位置类型 @@ -70,18 +66,18 @@ #### 模型所见 -该 seam 不添加提示词或 schema。Resume 将已存储接口事件恢复为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具和会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或请求用户,而不是盲目重试。 +该 seam 不添加提示词或 schema。恢复会将已存储的表层事件还原为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具和会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或询问用户,而不是盲目重试。 #### Token 影响 -普通持久化期间为零 token。Resume 恢复已保留历史成本,并正常支付当前请求 envelope;每个已修复调用添加引用的已保留错误文本。 +普通持久化期间为零 token。恢复后会重新计入保留历史的 token 用量,并照常计入当前请求 envelope 的 token 用量;每个已修复调用都会增加一段以引用形式保留的错误文本。 -#### KV 缓存影响 +#### KV Cache 影响 持久化不修改实时请求前缀。只有当重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。 -## 已知限制与待完成工作 +## 已知限制与暂缓事项 - **无删除或保留接口**:剪枝已存储会话是带外后端维护。 - **`list()` 无分页且无过滤**:它返回每个已存储会话的 header;适合本地存储,大规模时无索引。 -- **修复时合成 closer 是唯一崩溃方案**:后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次 resume。 +- **修复时合成 closer 是唯一崩溃方案**:后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。 diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index ca74dd487d..d1977d4512 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 51de7f9ee3..1ff5d0d3eb 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -7,21 +7,52 @@ import { Context } from 'cordis' import { + adoptSessionEvent, interruptedTurnClosers, SESSION_FORMAT_VERSION, + SessionPreparation, snapshotJsonValue, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionInspection } from './index.ts' +import type { SessionPersistenceRevision } from './revision.ts' +import { observeQueuedAbort, SessionPreparations } from './preparations.ts' +import type { SessionPreparationReservation } from './preparations.ts' + +/** Default number of detached session preparations retained by a coordinator. */ +export const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5 + +/** Durable session contents failed validation after a successful backend read. */ +export class SessionPersistenceCorruptionError extends Error { + /** + * @param message - stable corruption context. + * @param options - original validation failure. + */ + constructor(message: string, options: ErrorOptions) { + super(message, options) + this.name = 'SessionPersistenceCorruptionError' + } +} + +/** Coordinator policy supplied by a concrete persistence backend. */ +export interface PersistenceCoordinatorOptions { + /** Maximum completed unpublished preparations retained for reuse. */ + readonly preparedSessionCacheSize: number +} /** - * A stored session's header, valid contiguous event prefix, and optional opaque - * torn-tail marker. The coordinator only checks marker presence and returns its - * value to {@link PersistenceBackend.commitRepair}; each backend owns the type. + * A stored session's header, valid contiguous event prefix, source-qualified + * revision, and optional opaque torn-tail marker. The revision identifies the + * exact detached prefix. The coordinator only checks marker presence and + * returns its value to {@link PersistenceBackend.commitRepair}; each backend + * owns the marker type. */ export interface StoredPrefix<TornMarker = unknown> { meta: SessionHeader events: SessionEvent[] + /** Revision observed for exactly this detached prefix. */ + revision: SessionPersistenceRevision tornMarker?: TornMarker } @@ -55,12 +86,24 @@ export interface PersistenceBackend<TornMarker = unknown> { * `undefined` if no stored artifact exists. Returned metadata must identify * `id` before repair or state publication. Used by resume/load, live adoption, * and — via `!== undefined` — the create-collision probe. The returned - * `tornMarker` is present iff there is a torn tail to truncate. + * `tornMarker` is present iff there is a torn tail to truncate. Every header + * and event graph must be fresh, mutually unaliased, and unretained by the + * backend because preparation freezes and publishes them in place. The + * returned revision must identify exactly those values and use the same + * representation as {@link readStoredRevision}. * @param id - persisted session id to resolve. * @param signal - optional cancellation for backend read work. */ loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined> + /** + * Read the current source-qualified revision for one stored session without + * loading its event log. Returns `undefined` when the identity is absent. + * @param id - persisted session id to observe. + * @param signal - optional cancellation for backend read work. + */ + readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<SessionPersistenceRevision | undefined> + /** * Optional seek-capable suffix read behind the service's `readFrom`: return * the header plus the stored events with `seq >= fromSeq` without reading @@ -69,7 +112,10 @@ export interface PersistenceBackend<TornMarker = unknown> { * omit it and the coordinator falls back to {@link loadStored} plus a * forward skip. Non-mutating (no truncation, no closers). Validation of the * region strictly below `fromSeq` is limited to seq contiguity — the - * service contract scopes this read to the suffix. + * service contract scopes this read to the suffix — unless that suffix + * contains a supported legacy shape whose normalization needs earlier + * message-identity facts, in which case the coordinator falls back + * to the complete stored prefix. * @param id - persisted session id to resolve. * @param fromSeq - first event seq to include (non-negative safe integer, * validated by the coordinator before this hook runs). @@ -135,6 +181,17 @@ interface LiveSessionState { flush: Promise<void> | undefined } +/** One validated cold source and the exact unpublished Session built from it. */ +interface PreparedSessionSource<TornMarker> { + readonly inspection: SessionInspection + readonly session: Session + readonly revision: SessionPersistenceRevision + /** Session length after constructor-owned seed markers were appended. */ + readonly sessionLength: number + readonly tornMarker: TornMarker | undefined + readonly closers: readonly SessionEvent[] +} + /** Collect the rejection reasons from a set of promises (none-throwing). */ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unknown[]> { const settled = await Promise.allSettled([...promises]) @@ -180,6 +237,17 @@ function asRecord(value: unknown): Record<string, unknown> | undefined { : undefined } +/** Whether a record contains every required key and no key outside the optional extension set. */ +function hasOnlyKeys( + record: Record<string, unknown>, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = [...required, ...optional] + return Object.keys(record).every(key => allowed.includes(key)) + && required.every(key => Object.hasOwn(record, key)) +} + type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] /** Mint the stable import identity for a message persisted before identities existed. */ @@ -195,6 +263,138 @@ function replacementStart(event: SessionEvent): number | undefined { : undefined } +/** Whether one suffix event needs facts available only from the preceding stored prefix. */ +function needsLegacyPrefix(event: SessionEvent): boolean { + const data = asRecord(event.data) + const legacySteeringType: string = 'steering/message' + if (event.type === legacySteeringType) return true + if (data === undefined) return false + switch (event.type) { + case 'user/message': + return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') + case 'assistant/message': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') + case 'tool/result': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') + default: + return false + } +} + +/** Upgrade the removed steering surface event into its current user-message equivalent. */ +function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { + const legacyType: string = 'steering/message' + if (event.type !== legacyType) return event + const data = asRecord(event.data) + if (data === undefined) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const wrapped = asRecord(data['message']) + if (wrapped !== undefined && Number.isSafeInteger(data['turn']) + && hasOnlyKeys(data, ['turn', 'message'])) { + return { ...event, type: 'user/message', data: wrapped } as SessionEvent + } + if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const { turn: _turn, ...message } = data + return { + ...event, + type: 'user/message', + data: { + ...message, + id: legacyMessageId(id, event.seq), + role: 'user', + }, + } as SessionEvent +} + +/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */ +function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/start') return event + const data = asRecord(event.data) + if (data === undefined || !Object.hasOwn(data, 'trigger')) return event + const trigger = asRecord(data['trigger']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'trigger']) + || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) + } + return { ...event, data: { turn: data['turn'] } } as SessionEvent +} + +/** Upgrade an obsolete turn ending while preserving the latest-master envelope. */ +function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/end') return event + const data = asRecord(event.data) + /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */ + if (data === undefined) return event + const malformed = (): never => { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) + } + const reason = asRecord(data['reason']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'reason']) + || reason === undefined || typeof reason['kind'] !== 'string') return malformed() + + let currentReason: Record<string, unknown> | undefined + switch (reason['kind']) { + case 'completed': + case 'blocked': + case 'max-tokens': + case 'interrupted': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + return event + case 'aborted': + if (Object.hasOwn(reason, 'reason')) return event + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } + break + case 'disposed': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } + break + case 'error': { + if (Object.hasOwn(reason, 'error')) return event + if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() + const failure = asRecord(reason['failure']) + if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) + && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) + && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' + && (failure['status'] === undefined || typeof failure['status'] === 'number') + && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') + && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { + currentReason = { kind: 'error', error: failure } + break + } + const messageKeys = reason['code'] === undefined + ? ['kind', 'step', 'message'] + : ['kind', 'step', 'message', 'code'] + if (!hasOnlyKeys(reason, messageKeys) + || typeof reason['message'] !== 'string' + || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() + currentReason = { + kind: 'error', + error: { + message: reason['message'], + code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', + }, + } + break + } + default: + return event + } + + return { + ...event, + data: { + ...data, + reason: currentReason, + }, + } as SessionEvent +} + /** * Upgrade one pre-identity message event into the current wrapper shape. * Current-looking malformed events remain untouched so validation rejects them @@ -270,23 +470,6 @@ function migrateLegacyMessageEvent( }, } as SessionEvent } - case 'steering/message': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event - const { content, source, ...eventData } = data - return { - ...event, - data: { - ...eventData, - message: { - id: legacyMessageId(id, event.seq), - role: 'user', - content, - source, - }, - }, - } as SessionEvent - } default: return event } @@ -304,13 +487,32 @@ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): S assertSupportedEvents(events, id) const messageIds = new Map<number, PersistedMessageId>() return events.map((event) => { - const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(event, id, messageIds)) + const migratedStart = migrateLegacyTurnStartEvent(event, id) + const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) + const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) + const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) const messageId = eventMessageId(snapshot) if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) return snapshot }) } +/** Upgrade and validate an exclusively owned backend result without copying it. */ +function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] { + assertSupportedEvents(events, id) + const messageIds = new Map<number, PersistedMessageId>() + for (const [index, event] of events.entries()) { + const migratedStart = migrateLegacyTurnStartEvent(event, id) + const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) + const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) + const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) + events[index] = adopted + const messageId = eventMessageId(adopted) + if (messageId !== undefined) messageIds.set(adopted.seq, messageId) + } + return events +} + /** * Owns the backend-agnostic session write-path orchestration. A backend * constructs one (`new PersistenceCoordinator(ctx, this)`), implements @@ -331,15 +533,26 @@ export class PersistenceCoordinator<TornMarker = unknown> { private live = new Map<Session, LiveSessionState>() /** Exact disposed lifecycles whose eager tail is still draining. */ private retirements = new Map<SessionId, Promise<void>>() - /** Cold loads currently reserving an id across backend reads and repair writes. */ - private coldLoads = new Set<SessionId>() + /** Shared cold reads, unpublished reservations, and completed LRU entries. */ + private readonly preparations: SessionPreparations<PreparedSessionSource<TornMarker>, SessionState> /** * Per-session serialization: every operation chains onto the prior one for the * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map<SessionId, Promise<unknown>>() - constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) { + constructor( + private ctx: Context, + private backend: PersistenceBackend<TornMarker>, + options: PersistenceCoordinatorOptions = { + preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, + }, + ) { + if (!Number.isSafeInteger(options.preparedSessionCacheSize) + || options.preparedSessionCacheSize < 1) { + throw new TypeError('preparedSessionCacheSize must be a positive safe integer') + } + this.preparations = new SessionPreparations(options.preparedSessionCacheSize) this.installWritePath() } @@ -363,7 +576,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { private async createCore(meta: SessionHeader): Promise<void> { // Do NOT clobber an existing session: the SessionId IS the identity. - if (this.states.has(meta.id)) { + if (this.states.has(meta.id) || this.preparations.has(meta.id)) { throw new Error(`session "${meta.id}" already exists in this backend`) } // A persisted artifact under this id (in ANY scope) blocks creation: load/ @@ -405,8 +618,9 @@ export class PersistenceCoordinator<TornMarker = unknown> { // this same backend will refuse to load. assertSupportedEvents(events, id) if (events.length === 0) return + this.preparations.assertWritable(id) let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls loadCore, not load + if (state === undefined) state = await this.adopt(id) // Contiguity contract: each event's seq must continue the stored log. for (const [i, event] of events.entries()) { @@ -420,67 +634,115 @@ export class PersistenceCoordinator<TornMarker = unknown> { // cursor as soon as it commits (uniform across backends). state.materialized = true state.cursor += events.length + this.preparations.invalidate(id) } /** - * Reload a session: its {@link SessionHeader} plus the event log up to the last - * durable checkpoint, with any interrupted final turn durably closed (synthetic - * boundary events) during load. - * @param id - the persisted session to reload. - * @returns the header plus the event log, ending on a balanced `turn/end`. + * Prepare and reserve the exact unpublished Session used by resume. + * Revision retries converge once the durable log remains unchanged for one + * read/check round trip; continuous external writers may delay completion. + * @param id - persisted session to prepare. + * @param signal - optional cancellation for reading and repair. + * @returns an owned preparation released after publication or rollback. */ - async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - await this.retirements.get(id) - const selected = await this.serialize(id, async () => { - const live = this.ctx.sessions.get(id) - if (live !== undefined) return { live } - this.coldLoads.add(id) - try { - return { loaded: await this.loadCore(id) } - } finally { - this.coldLoads.delete(id) + async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> { + for (;;) { + await this.waitForRetirement(id, signal) + if (this.ctx.sessions.get(id) !== undefined) { + throw new Error(`cannot prepare session "${id}" while it is live`) } - }) - return 'loaded' in selected ? selected.loaded : this.loadLiveSnapshot(selected.live) + const reservation = await this.preparations.reserve( + id, + () => this.serialize(id, () => this.prepareCore(id)), + source => this.serialize(id, () => this.commitPrepared(source), signal), + signal, + ) + if (reservation === undefined) continue + if (this.ctx.sessions.get(id) !== undefined) { + this.preparations.release(reservation, false) + throw new Error(`cannot prepare session "${id}" while it is live`) + } + return SessionPreparation.create(reservation.source.session, { + release: () => { + this.preparations.release( + reservation, + reservation.state.owner === undefined + && reservation.source.session.events.length === reservation.source.sessionLength, + ) + }, + }) + } } /** - * Read a detached valid stored prefix without recovery mutations or - * coordinator-state publication. - * @param id - persisted session to inspect. - * @param signal - optional cancellation for queued and backend read work. - * @returns stored header and events before any synthetic recovery closers. + * Commit recovery and return its immutable logical view without publication. + * Revision retries converge once the durable log remains unchanged for one + * read/check round trip; continuous external writers may delay completion. + * @param id - persisted session to load. + * @returns prepared header and balanced events. */ - inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - // Waiting for an in-flight retirement drain must honor cancellation too: a - // slow drain would otherwise pin a cancelled inspect until it finishes, - // past the documented boundary. serialize() already races the signal for - // the queued read; do the same for the retirement wait. - const retired = Promise.resolve(this.retirements.get(id)) - const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) - return waited.then(() => this.serialize(id, () => this.inspectCore(id, signal), signal)) + async load(id: SessionId): Promise<SessionInspection> { + for (;;) { + await this.waitForRetirement(id) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return this.loadLiveSnapshot(live) + const reservation = await this.preparations.reserve( + id, + () => this.serialize(id, () => this.prepareCore(id)), + source => this.serialize(id, () => this.commitPrepared(source)), + ) + if (reservation === undefined) continue + const attached = this.ctx.sessions.get(id) + if (attached !== undefined) { + this.preparations.discard(reservation) + return this.loadLiveSnapshot(attached) + } + this.preparations.discard(reservation) + return reservation.source.inspection + } } - private async inspectCore( - id: SessionId, - signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - let stored: StoredPrefix<TornMarker> | undefined - try { - stored = await this.backend.loadStored(id, signal) - } catch (error: unknown) { - if (signal?.aborted) signal.throwIfAborted() - throw error - } - signal?.throwIfAborted() - if (stored === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, stored.meta) - this.assertVersion(stored.meta) - const events = snapshotStoredEvents(stored.events, id) - return { - meta: structuredClone(stored.meta), - events, + /** + * Inspect a logical session without publishing it or committing recovery. + * A stale ready source is reloaded. A source already committing or reserved + * for resume remains exclusive, and inspection may borrow its immutable view. + * Revision retries converge once the log is stable for one read/check round + * trip; continuous external writers may delay completion. + * @param id - persisted session to inspect. + * @param signal - optional cancellation for preparation work. + * @returns immutable prepared metadata and events; a live view may have an open turn. + */ + async inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> { + for (;;) { + signal?.throwIfAborted() + if (this.retirements.has(id)) await this.waitForRetirement(id, signal) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return this.inspectLive(live) + try { + const source = await this.preparations.inspect( + id, + () => this.serialize(id, () => this.prepareCore(id)), + signal, + ) + const attached = this.ctx.sessions.get(id) + if (attached !== undefined) return this.inspectLive(attached) + const current = await this.serialize( + id, + () => this.isPreparedSourceCurrent(source, signal), + signal, + ) + const published = this.ctx.sessions.get(id) + if (published !== undefined) return this.inspectLive(published) + if (current) return source.inspection + if (this.preparations.discardReady(id, source) === 'retained') { + return source.inspection + } + } catch (error: unknown) { + signal?.throwIfAborted() + const attached = this.ctx.sessions.get(id) + if (attached !== undefined) return this.inspectLive(attached) + throw error + } } } @@ -522,48 +784,137 @@ export class PersistenceCoordinator<TornMarker = unknown> { if (suffix === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, suffix.meta) this.assertVersion(suffix.meta) - assertSupportedEvents(suffix.events, id) - return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) } + if (suffix.events.some(needsLegacyPrefix)) { + const whole = await this.readStoredPrefix(id, signal) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) } } - const whole = await this.inspectCore(id, signal) + const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. return { meta: whole.meta, events: whole.events.slice(fromSeq) } } - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const stored = await this.backend.loadStored(id) + /** Read one detached physical prefix without logical recovery or caching. */ + private async readStoredPrefix( + id: SessionId, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + signal?.throwIfAborted() + const stored = await this.backend.loadStored(id, signal) + signal?.throwIfAborted() if (stored === undefined) throw new Error(`session "${id}" not found`) - const { meta, events, tornMarker } = stored - this.assertStoredId(id, meta) - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, id) - - // Preserve complete interrupted events and synthesize only missing closers. - const closers = interruptedTurnClosers(storedEvents).map(snapshotSessionEvent) - const balanced = [...storedEvents, ...closers] - - // Repair storage before publishing coordinator state. - if (tornMarker !== undefined || closers.length > 0) { - await this.backend.commitRepair(meta, tornMarker, closers) + this.assertStoredId(id, stored.meta) + this.assertVersion(stored.meta) + return { + meta: structuredClone(stored.meta), + events: snapshotStoredEvents(stored.events, id), } - // Keep coordinator metadata detached from the returned record. - this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) - return { meta: structuredClone(meta), events: balanced } } - /** Return a durable balanced live snapshot without applying cold crash repair. */ - private async loadLiveSnapshot(session: Session): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const events = session.events.map(snapshotSessionEvent) + /** Read, repair in memory, validate, and freeze one cold source once. */ + private async prepareCore(id: SessionId): Promise<PreparedSessionSource<TornMarker>> { + const stored = await this.backend.loadStored(id) + if (stored === undefined) throw new Error(`session "${id}" not found`) + try { + const { meta, events, revision, tornMarker } = stored + this.assertStoredId(id, meta) + this.assertVersion(meta) + const storedEvents = adoptStoredEvents(events, id) + + // Preserve complete interrupted events and synthesize only missing closers. + const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) + const balanced = [...storedEvents, ...closers] + const session = this.ctx.sessions.prepare(id, { + seed: balanced, + meta, + seedSource: 'persistence', + }) + const inspection: SessionInspection = Object.freeze({ + meta: session.header, + events: Object.freeze(balanced), + }) + return { + inspection, + session, + revision, + sessionLength: session.events.length, + tornMarker, + closers, + } + } catch (error: unknown) { + throw new SessionPersistenceCorruptionError( + `stored session "${id}" failed validation: ${String(error)}`, + { cause: error }, + ) + } + } + + /** Commit one prepared repair and establish its ownerless durable cursor. */ + private async commitPrepared( + source: PreparedSessionSource<TornMarker>, + ): Promise<{ source: PreparedSessionSource<TornMarker>; state: SessionState } | undefined> { + const id = source.inspection.meta.id + const cursor = source.inspection.events.length + const existing = this.states.get(id) + if (existing?.owner !== undefined) { + throw new Error(`session "${id}" already has a live persistence owner`) + } + if (!await this.isPreparedSourceCurrent(source)) return undefined + if (source.tornMarker !== undefined || source.closers.length > 0) { + await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers) + // The repair changed the durable revision. Reload the exact committed + // graph instead of associating the old in-memory view with a newer revision. + return undefined + } + const state = existing ?? { + meta: source.inspection.meta, + cursor, + materialized: true, + } + state.meta = source.inspection.meta + state.cursor = cursor + state.materialized = true + this.states.set(id, state) + return { + source, + state, + } + } + + /** Whether one cached source still names the current durable log revision. */ + private async isPreparedSourceCurrent( + source: PreparedSessionSource<TornMarker>, + signal?: AbortSignal, + ): Promise<boolean> { + return await this.backend.readStoredRevision(source.inspection.meta.id, signal) === source.revision + } + + /** Return one durable immutable view of an already-live Session. */ + private async loadLiveSnapshot(session: Session): Promise<SessionInspection> { + const events = session.events await this.flush(session) const state = this.states.get(session.id) /* v8 ignore next -- successful flush always publishes this live session's durable state */ if (state === undefined) throw new Error(`session "${session.id}" lost persistence state during load`) - const meta = structuredClone(state.meta) if (events.length === 0) throw new Error(`session "${session.id}" not found`) if (interruptedTurnClosers(events).length > 0) { throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) } - return { meta, events } + return Object.freeze({ meta: state.meta, events }) + } + + /** Borrow one immutable view from an already-live Session. */ + private inspectLive(session: Session): SessionInspection { + return Object.freeze({ meta: session.header, events: session.events }) + } + + /** Await one retiring lifecycle with caller cancellation. */ + private waitForRetirement(id: SessionId, signal?: AbortSignal): Promise<void> { + const retired = Promise.resolve(this.retirements.get(id)) + return signal === undefined + ? retired + : observeQueuedAbort(retired, signal, () => false) } // Listing is a direct backend read and needs no coordinator state. @@ -603,13 +954,13 @@ export class PersistenceCoordinator<TornMarker = unknown> { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise<SessionState> { - // loadCore (NOT load) — adopt runs inside an already-serialized op, so - // re-entering the chain via the public load() would deadlock. - await this.loadCore(id) - const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ - if (!state) throw new Error(`failed to adopt session "${id}"`) - return state + // This runs inside the id's serialization chain, so it uses core helpers + // instead of re-entering through public prepare/load methods. + for (;;) { + const source = this.preparations.takeReady(id) ?? await this.prepareCore(id) + const committed = await this.commitPrepared(source) + if (committed !== undefined) return committed.state + } } private assertVersion(meta: SessionHeader): void { @@ -660,9 +1011,6 @@ export class PersistenceCoordinator<TornMarker = unknown> { // Capture the header on creation and persist a fork's seed once. ctx.on('session/created', (session) => { - if (this.coldLoads.has(session.id)) { - throw new Error(`cannot publish session "${session.id}" while its persisted history is loading`) - } void this.initFor(session) }) @@ -712,6 +1060,12 @@ export class PersistenceCoordinator<TornMarker = unknown> { private initFor(session: Session): LiveSessionState { const existing = this.live.get(session) if (existing) return existing + const reservation = this.preparations.reservationFor(session) + if (reservation !== undefined) { + const restored = this.attachPrepared(session, reservation) + this.live.set(session, restored) + return restored + } const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) @@ -720,6 +1074,28 @@ export class PersistenceCoordinator<TornMarker = unknown> { return live } + /** Bind one exact prepared Session and persist only its unpublished suffix. */ + private attachPrepared( + session: Session, + reservation: SessionPreparationReservation<PreparedSessionSource<TornMarker>, SessionState>, + ): LiveSessionState { + const { source, state } = reservation + if (source.session !== session || state.owner !== undefined + || state.cursor !== source.inspection.events.length + || session.firstLiveSeq !== state.cursor) { + throw new Error(`session "${session.id}" preparation no longer matches its persistence state`) + } + const suffix = session.events.slice(state.cursor).map(event => structuredClone(event)) + this.preparations.attach(reservation) + state.owner = session + const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } + if (suffix.length > 0) { + live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix)) + live.init.catch(() => { /* observed by flush/dispose through the controller */ }) + } + return live + } + /** * Whether a live session's `seed` reproduces the first `cursor` persisted * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when @@ -787,7 +1163,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { // cwd mismatch before repair or state publication. const live = await this.backend.loadStored(id) if (live !== undefined) { - // Do NOT route through loadCore(): that crash-repairs open turns as + // Do NOT route through cold preparation: that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. await this.adoptLivePrefix(session, seed, live) @@ -876,50 +1252,3 @@ export class PersistenceCoordinator<TornMarker = unknown> { live.pending.splice(0, batch.length) } } - -/** - * Give an observation caller a prompt cancellation view of queued work. - * - * The serialized `operation` remains in the same-id chain and checks the signal - * before invoking backend work. Observing its settlement here therefore cannot - * detach a storage read or let a later operation overtake its predecessor. - */ -function observeQueuedAbort<T>( - operation: Promise<T>, - signal: AbortSignal, - started: () => boolean, -): Promise<T> { - return new Promise<T>((resolve, reject) => { - let settled = false - const finish = (callback: () => void): void => { - if (settled) return - settled = true - signal.removeEventListener('abort', onAbort) - callback() - } - const onAbort = (): void => { - if (started()) return - finish(() => { - try { - signal.throwIfAborted() - } catch (reason: unknown) { - rejectObservation(reject, reason) - return - } - /* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted */ - reject(new Error('persistence observation abort event lacked an aborted signal')) - }) - } - signal.addEventListener('abort', onAbort, { once: true }) - operation.then( - (value) => { finish(() => { resolve(value) }) }, - (reason: unknown) => { finish(() => { rejectObservation(reject, reason) }) }, - ) - if (signal.aborted) onAbort() - }) -} - -/** Preserve an exact provider or AbortSignal reason, including legacy non-Error values. */ -function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void { - reject(reason) -} diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 5d4f5b616e..94596855df 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -6,6 +6,7 @@ */ import { Context, Service } from 'cordis' +import { SessionPreparation } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' @@ -21,9 +22,26 @@ export interface SessionPersistenceSnapshot { revision: SessionPersistenceRevision } +/** Immutable logical session prepared from persistence or a live owner. */ +export interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + readonly events: readonly SessionEvent[] +} + // The backend-agnostic write-path orchestration first-party backends compose. -export { PersistenceCoordinator } from './coordinator.ts' -export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts' +export { + DEFAULT_PREPARED_SESSION_CACHE_SIZE, + PersistenceCoordinator, + SessionPersistenceCorruptionError, +} from './coordinator.ts' +export type { + PersistenceBackend, + PersistenceCoordinatorOptions, + StoredPrefix, + StoredSuffix, +} from './coordinator.ts' declare module 'cordis' { interface Context { @@ -83,46 +101,72 @@ export abstract class SessionPersistence extends Service { abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void> /** - * Load a header and balanced contiguous log. A complete interrupted final - * turn is preserved and durably closed with missing tool errors plus any open - * step and turn boundaries; only a torn final record is discarded. Unknown - * versions and corruption in the committed prefix reject. Implementations - * MUST NOT crash-repair an identity still bound to a live Session: a balanced - * live log may return with its stored header as a durable snapshot, while an - * open live turn rejects. - * A coordinator-backed cold load reserves the identity across storage awaits, - * so concurrent publication of a same-id live Session rejects. - * Returned events are detached, and every identified message is deeply - * frozen. Coordinator-backed implementations upgrade supported pre-identity - * message events before validation; other malformed messages reject before - * any stored event is returned. + * Prepare the exact unpublished Session used by resume. Implementations may + * reuse object graphs retained by an earlier {@link inspect} after confirming + * their durable revision is still current; disposal releases an unpublished + * reservation. Revision retries require the durable log to remain unchanged + * for one read/check round trip; continuous external writers may delay completion. + * @param id - persisted session to prepare. + * @param signal - optional cancellation for preparation work. + * @returns one owned unpublished Session preparation. + */ + async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> { + signal?.throwIfAborted() + const loaded = await this.load(id) + signal?.throwIfAborted() + const sessions = this.ctx.get('sessions') + if (sessions === undefined) { + throw new Error('cannot prepare a session: SessionStore is not configured') + } + return SessionPreparation.create(sessions.prepare(id, { + seed: loaded.events.map(event => structuredClone(event)), + meta: structuredClone(loaded.meta), + seedSource: 'persistence', + })) + } + + /** + * Load an immutable balanced logical view and commit any required cold + * recovery. A complete interrupted final turn is preserved and durably + * closed with missing tool errors plus any open step and turn boundaries; + * only a torn final record is discarded. Unknown versions and corruption in + * the committed prefix reject. Implementations MUST NOT crash-repair an + * identity still bound to a live Session: a balanced live log may return as a + * durable snapshot, while an open live turn rejects. Returned values may be + * shared with immutable live or prepared state and must not be mutated. + * Revision-based implementations may wait for one stable read/check round trip. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ - abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + abstract load(id: SessionId): Promise<SessionInspection> /** - * Inspect a header and its valid contiguous stored prefix without repairing - * a torn tail, closing an interrupted turn, or publishing coordinator state. - * This read is serialized with writes for the same id and returns detached - * values with upgraded, deeply frozen identified messages, so observers - * cannot mutate message identity/content or backend-owned state. Other - * malformed messages reject. + * Inspect an immutable logical session without committing recovery or + * publishing it. A cold complete interrupted turn receives synthetic closers + * in memory and a torn physical tail remains untouched. An already-live + * Session instead yields its current immutable snapshot, which may contain an + * open turn and its `session/end-seed` boundary. Coordinator-backed + * implementations retain the exact cold unpublished Session for bounded + * reuse by a later {@link prepare}. A stale ready source is reloaded; a source + * already committing or reserved for resume remains exclusive, and inspection + * may borrow its immutable view. Callers borrow only the immutable header and + * log. Continuous external writers may delay revision convergence. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. - * @returns the header and valid stored event prefix exactly as observed. + * @returns the validated header and current logical event log. */ - abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted - * projection cache folding only the tail past its checkpoint). Like - * {@link inspect} it is non-mutating and detached: no torn-tail truncation, - * no synthetic closers, no coordinator-state publication; only events from - * the valid contiguous stored prefix are returned, so a torn fragment never - * reaches the caller. `fromSeq` at or beyond the stored prefix returns an - * empty event list (never an error). Backends whose medium can seek by seq + * projection cache folding only the tail past its checkpoint). Unlike + * {@link inspect}, it is a detached physical suffix read: no preparation + * cache, torn-tail truncation, synthetic closers, or coordinator-state + * publication. Only events from the valid contiguous stored prefix are + * returned, so a torn fragment never reaches the caller. `fromSeq` at or + * beyond the stored prefix returns an empty event list (never an error). + * Backends whose medium can seek by seq * (SQLite) read only the suffix; sequential media (JSONL, both encodings) * still parse the whole artifact and skip forward — the primitive bounds * what is RETURNED and refolded, not every backend's physical read. diff --git a/packages/session-persistence/session-persistence/src/preparations.ts b/packages/session-persistence/session-persistence/src/preparations.ts new file mode 100644 index 0000000000..2a685f71f9 --- /dev/null +++ b/packages/session-persistence/session-persistence/src/preparations.ts @@ -0,0 +1,348 @@ +/** + * Bounded sharing and exclusive reservation of unpublished Sessions. + * @module @deepseek-ai/dsh-session-persistence/preparations + */ + +import type { Session, SessionId } from '@deepseek-ai/dsh-session' + +interface PreparedSource { + readonly session: Session +} + +type PreparationPhase = 'loading' | 'ready' | 'committing' | 'reserved' + +interface PreparationEntry<Source, CommitState> { + readonly id: SessionId + readonly result: Promise<Source> + phase: PreparationPhase + source?: Source + reservation?: SessionPreparationReservation<Source, CommitState> + reservationSettled?: Promise<void> + settleReservation?: () => void +} + +/** One exclusively held prepared source and its committed persistence state. */ +export interface SessionPreparationReservation<Source, CommitState> { + readonly entry: PreparationEntry<Source, CommitState> + readonly source: Source + readonly state: CommitState +} + +/** Per-coordinator cold-read sharing, exclusive reservation, and ready-entry LRU. */ +export class SessionPreparations<Source extends PreparedSource, CommitState> { + private readonly entries = new Map<SessionId, PreparationEntry<Source, CommitState>>() + + constructor(private readonly capacity: number) {} + + /** + * Whether this pool currently knows about an unpublished identity. + * @param id - session identity. + * @returns whether an entry exists for the identity. + */ + has(id: SessionId): boolean { + return this.entries.has(id) + } + + /** + * Observe one prepared source, sharing an in-flight read for the same id. + * @param id - session identity. + * @param load - cold loader used when no entry exists. + * @param signal - optional cancellation signal while waiting. + * @returns the shared prepared source. + */ + async inspect( + id: SessionId, + load: () => Promise<Source>, + signal?: AbortSignal, + ): Promise<Source> { + const entry = this.entryFor(id, load) + const loaded = signal === undefined + ? await entry.result + : await observeQueuedAbort(entry.result, signal) + const source = entry.source ?? loaded + if (this.entries.get(id) === entry && entry.phase === 'ready') this.touch(entry) + return source + } + + /** + * Reserve one ready source after committing its pending durable repair. + * @param id - session identity. + * @param load - cold loader used when no entry exists. + * @param commit - durable repair and cursor-state commit. + * @param signal - optional cancellation signal while waiting. + * @returns the exclusive reservation, or undefined if its entry was invalidated. + */ + async reserve( + id: SessionId, + load: () => Promise<Source>, + commit: (source: Source) => Promise<{ source: Source; state: CommitState } | undefined>, + signal?: AbortSignal, + ): Promise<SessionPreparationReservation<Source, CommitState> | undefined> { + const entry = this.entryFor(id, load) + await (signal === undefined ? entry.result : observeQueuedAbort(entry.result, signal)) + while (this.entries.get(id) === entry && entry.phase !== 'ready') { + const settled = entry.reservationSettled + /* v8 ignore next -- committing/reserved transitions install this waiter synchronously. */ + if (settled === undefined) throw new Error(`session "${id}" preparation lost its reservation waiter`) + if (signal === undefined) await settled + else await observeQueuedAbort(settled, signal) + } + if (this.entries.get(id) !== entry) return undefined + const source = entry.source as Source + const reservationSettled = Promise.withResolvers<void>() + entry.phase = 'committing' + entry.reservationSettled = reservationSettled.promise + entry.settleReservation = reservationSettled.resolve + let committed: { source: Source; state: CommitState } | undefined + try { + committed = await commit(source) + } catch (error: unknown) { + this.remove(entry) + throw error + } + if (committed === undefined) { + this.remove(entry) + return undefined + } + entry.source = committed.source + try { + signal?.throwIfAborted() + } catch (error: unknown) { + this.makeReady(entry) + throw error + } + if (this.entries.get(id) !== entry) return undefined + const reservation: SessionPreparationReservation<Source, CommitState> = { + entry, + source: committed.source, + state: committed.state, + } + entry.phase = 'reserved' + entry.reservation = reservation + return reservation + } + + /** + * Return the exact reservation for Session publication, rejecting aliases. + * @param session - exact Session candidate for publication. + * @returns its reservation, or undefined when no preparation exists. + */ + reservationFor(session: Session): SessionPreparationReservation<Source, CommitState> | undefined { + const entry = this.entries.get(session.id) + if (entry === undefined) return undefined + if (entry.phase === 'reserved' + && entry.source?.session === session + && entry.reservation !== undefined) { + return entry.reservation + } + throw new Error(`cannot publish session "${session.id}": persisted state already owns this identity`) + } + + /** + * Consume a reservation after its exact Session has attached. + * @param reservation - reservation to consume. + */ + attach(reservation: SessionPreparationReservation<Source, CommitState>): void { + const { entry } = reservation + if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) { + throw new Error(`session "${entry.id}" preparation is no longer reserved`) + } + this.remove(entry) + } + + /** + * Consume a reservation whose caller only needs the committed inspection. + * @param reservation - reservation to consume. + */ + discard(reservation: SessionPreparationReservation<Source, CommitState>): void { + const { entry } = reservation + if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) return + this.remove(entry) + } + + /** + * Return a reusable unpublished reservation to the ready LRU. + * @param reservation - reservation to release. + * @param reusable - whether the source remains valid for reuse. + */ + release( + reservation: SessionPreparationReservation<Source, CommitState>, + reusable: boolean, + ): void { + const { entry } = reservation + if (this.entries.get(entry.id) !== entry + || entry.reservation !== reservation + || entry.phase !== 'reserved') return + if (!reusable) { + this.remove(entry) + return + } + delete entry.reservation + this.makeReady(entry) + } + + /** + * Discard a prepared view after the durable log changes. + * @param id - changed session identity. + */ + invalidate(id: SessionId): void { + const entry = this.entries.get(id) + if (entry !== undefined) this.remove(entry) + } + + /** + * Discard an exact stale ready source without disturbing an exclusive owner. + * @param id - changed session identity. + * @param expected - exact source observed before its revision check. + * @returns whether the source was discarded, retained by a reservation, or is absent. + */ + discardReady(id: SessionId, expected: Source): 'discarded' | 'retained' | 'missing' { + const entry = this.entries.get(id) + if (entry === undefined || entry.source !== expected) return 'missing' + if (entry.phase !== 'ready') return 'retained' + this.remove(entry) + return 'discarded' + } + + /** + * Reject writes while an unpublished Session exclusively reserves the id. + * @param id - session identity to check. + */ + assertWritable(id: SessionId): void { + const phase = this.entries.get(id)?.phase + if (phase === 'committing' || phase === 'reserved') { + throw new Error(`cannot append session "${id}" while its persisted preparation is reserved`) + } + } + + /** + * Remove a completed entry for an already-serialized append adoption. + * @param id - adopted session identity. + * @returns the prepared source, or undefined when no ready entry exists. + */ + takeReady(id: SessionId): Source | undefined { + const entry = this.entries.get(id) + if (entry === undefined || entry.phase !== 'ready' || entry.source === undefined) return undefined + this.remove(entry) + return entry.source + } + + private entryFor( + id: SessionId, + load: () => Promise<Source>, + ): PreparationEntry<Source, CommitState> { + const existing = this.entries.get(id) + if (existing !== undefined) return existing + const deferred = Promise.withResolvers<Source>() + const entry: PreparationEntry<Source, CommitState> = { + id, + result: deferred.promise, + phase: 'loading', + } + this.entries.set(id, entry) + let loading: Promise<Source> + try { + // Start immediately so a same-tick serialized append queues behind this + // read. The deferred result settles only after the entry becomes ready. + loading = load() + } catch (error: unknown) { + this.remove(entry) + deferred.reject(error) + return entry + } + void loading.then((source) => { + if (this.entries.get(id) === entry) { + entry.source = source + this.makeReady(entry) + } + deferred.resolve(source) + }, (error: unknown) => { + this.remove(entry) + deferred.reject(error) + }) + return entry + } + + private makeReady(entry: PreparationEntry<Source, CommitState>): void { + if (this.entries.get(entry.id) !== entry) return + entry.phase = 'ready' + const settle = entry.settleReservation + delete entry.reservationSettled + delete entry.settleReservation + settle?.() + this.touch(entry) + } + + private remove(entry: PreparationEntry<Source, CommitState>): void { + if (this.entries.get(entry.id) !== entry) return + this.entries.delete(entry.id) + const settle = entry.settleReservation + delete entry.reservationSettled + delete entry.settleReservation + settle?.() + } + + private touch(entry: PreparationEntry<Source, CommitState>): void { + this.entries.delete(entry.id) + this.entries.set(entry.id, entry) + let readyCount = 0 + for (const candidate of this.entries.values()) { + if (candidate.phase === 'ready') readyCount += 1 + } + if (readyCount <= this.capacity) return + for (const [id, candidate] of this.entries) { + if (candidate.phase !== 'ready') continue + this.entries.delete(id) + return + } + } +} + +/** + * Give a queued observer a prompt cancellation view without cancelling shared work. + * @param operation - shared operation whose settlement remains authoritative. + * @param signal - observer-local cancellation signal. + * @param started - whether the operation has crossed its cancellation cutoff. + * @returns the operation result or the observer's prompt cancellation. + */ +export function observeQueuedAbort<T>( + operation: Promise<T>, + signal: AbortSignal, + started: () => boolean = () => false, +): Promise<T> { + return new Promise<T>((resolve, reject) => { + let settled = false + const finish = (callback: () => void): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + callback() + } + const onAbort = (): void => { + if (started()) return + finish(() => { + try { + signal.throwIfAborted() + } catch (reason: unknown) { + rejectObservation(reject, reason) + return + } + /* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted. */ + reject(new Error('queued observation abort event lacked an aborted signal')) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { finish(() => { resolve(value) }) }, + (reason: unknown) => { + finish(() => { rejectObservation(reject, reason) }) + }, + ) + if (signal.aborted) onAbort() + }) +} + +/** Preserve an exact loader or AbortSignal reason, including legacy non-Error values. */ +function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void { + reject(reason) +} diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 9ff82101d7..a672884e46 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -33,7 +33,7 @@ export function meta(id: string, cwd?: string): SessionHeader { /** A well-formed one-turn event log (contiguous seqs from 0). */ export function oneTurnLog(): SessionEvent[] { return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 2, data: freezeMessage({ id: MessageId('one-turn-user'), role: 'user', @@ -124,7 +124,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac // A second turn that crashed mid-flight: turn/start + step/start were // durably written, but no step/end / turn/end ever arrived. await persistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ]) const beforeRepair = (await persistence.listSnapshots()) @@ -136,7 +136,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac expect(afterInspect).toBe(beforeRepair) expect(inspected.events.map(e => e.type)).toEqual([ 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', - 'turn/start', 'step/start', + 'turn/start', 'step/start', 'step/end', 'turn/end', ]) // load PRESERVES the interrupted turn's events (a turn can be huge — they @@ -157,7 +157,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac // The closed log is durable and continuable: a fresh append continues at // the balanced length (seq 10), and a reload round-trips identically. await persistence.append(m.id, [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await persistence.load(m.id) @@ -177,7 +177,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac // BEFORE the tool/result was written (the loop runs tools after logging // the assistant message — a process killed mid-tool lands exactly here). await persistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, { type: 'assistant/message', seq: 8, time: 9, data: { turn: 2, step: 1, @@ -191,7 +191,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac ...{ provider: 'mock', model: 'mock' }, }, }), - } }, + }, surfaceOp: 'append' }, ]) const loaded = await persistence.load(m.id) @@ -227,7 +227,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac const m = meta('unknown-tool-outcome') await persistence.create(m) await persistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, @@ -255,7 +255,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac } expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent') expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user') - const resumed = new Session(m.id, loaded.events, loaded.meta) + const resumed = Session.create(m.id, loaded.events, loaded.meta) const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result')) expect(resumedResult?.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('call-risk'), isError: true, @@ -318,7 +318,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac // Non-mutating: an interrupted-turn log is served as stored, no closers. await persistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, ]) const tail = await persistence.readFrom(m.id, 6) expect(tail.events.map(event => event.type)).toEqual(['turn/start']) @@ -347,7 +347,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac type: 'turn/start', seq: 6, time: 7, - data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 2 }, }]) const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) expect(changed?.revision).not.toBe(first?.revision) @@ -376,7 +376,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac const m = meta('s4') await persistence.create(m) const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1 ] await expect(persistence.append(m.id, gapped)).rejects.toThrow() diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 9ad6ff6c87..411df34d8d 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -48,7 +48,7 @@ function send(session: Session, events: readonly SessionEvent[]): void { /** A valid persisted log from immediately before messages gained wrappers and identities. */ function legacyMessageLog(): SessionEvent[] { return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, @@ -89,20 +89,9 @@ function legacyMessageLog(): SessionEvent[] { sourceEventSeqs: [4], surfaceOp: 'append', }, - { - type: 'steering/message', - seq: 6, - time: 7, - data: { - turn: 1, - content: [{ type: 'text', text: 'continue' }], - source: { kind: 'plugin', plugin: 'test' }, - }, - surfaceOp: 'append', - }, { type: 'tool/result', - seq: 7, + seq: 6, time: 8, data: { turn: 1, @@ -114,8 +103,98 @@ function legacyMessageLog(): SessionEvent[] { sourceEventSeqs: [5], surfaceOp: { op: 'replace', start: 5, end: 5 }, }, - { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 8, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as unknown as SessionEvent[] +} + +/** A complete log in the durable event vocabulary of the react-loop refactor base. */ +function preReactLoopLog(): SessionEvent[] { + const prompt = createUserMessage({ + content: [{ type: 'text', text: 'old prompt' }], + source: { kind: 'user' }, + }) + const steering = createUserMessage({ + content: [{ type: 'text', text: 'old steering' }], + source: { kind: 'user' }, + }) + return [ + { + type: 'turn/start', seq: 0, time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { type: 'user/message', seq: 1, time: 2, data: prompt, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'steering/message', seq: 3, time: 4, + data: { turn: 1, message: steering }, + surfaceOp: 'append', + }, + { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'retry' } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'step/end', seq: 8, time: 9, data: { turn: 2, step: 1 } }, + { + type: 'turn/end', seq: 9, time: 10, + data: { + turn: 2, + reason: { + kind: 'error', + step: 1, + failure: { message: 'old provider failure', code: 'SERVER' }, + }, + }, + }, + { + type: 'turn/start', seq: 10, time: 11, + data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'aborted' } } }, + { + type: 'turn/start', seq: 12, time: 13, + data: { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { type: 'turn/end', seq: 13, time: 14, data: { turn: 4, reason: { kind: 'disposed' } } }, + { + type: 'turn/start', seq: 14, time: 15, + data: { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { type: 'step/start', seq: 15, time: 16, data: { turn: 5, step: 1 } }, + { type: 'step/end', seq: 16, time: 17, data: { turn: 5, step: 1 } }, + { + type: 'turn/end', seq: 17, time: 18, + data: { turn: 5, reason: { kind: 'error', step: 1, message: 'old thrown value' } }, + }, + { + type: 'turn/start', seq: 18, time: 19, + data: { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'turn/end', seq: 19, time: 20, + data: { + turn: 6, + reason: { + kind: 'error', + step: 0, + failure: { + message: 'old detailed provider failure', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1000, + requestId: 'request-1', + }, + }, + }, + }, + { + type: 'turn/start', seq: 20, time: 21, + data: { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'turn/end', seq: 21, time: 22, + data: { turn: 7, reason: { kind: 'error', step: 0, message: 'old coded error', code: 'CODED' } }, + }, ] as unknown as SessionEvent[] } @@ -171,7 +250,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) try { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await ctx.sessions.flush(session) await expect(ctx.sessionPersistence.load(session.id)) @@ -206,7 +285,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } await ctx.sessionPersistence.create(header) await ctx.sessionPersistence.append(id, [start]) @@ -276,12 +355,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< let session!: Session const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('delegated-child'), { - meta: { - cwd: WORK, - parentSession: SessionId('root'), - origin: 'subagent', - delegationDepth: 2, - }, + meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 }, }) }, { inject: ['sessions'] })) send(session, oneTurnLog()) @@ -290,7 +364,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child')) expect(loaded.meta.delegationDepth).toBe(2) - expect(loaded.meta.origin).toBe('subagent') } finally { await fiber.dispose() await fix.cleanup() @@ -302,7 +375,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const ev = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -363,30 +436,143 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.inspect(id), await ctx.sessionPersistence.load(id), ]) { - const messages = snapshot.events.flatMap((event) => { - if (event.type === 'user/message') return [event.data] - if (event.type === 'assistant/message' - || event.type === 'tool/result' - || event.type === 'steering/message') return [event.data.message] - return [] - }) + const messages: { id: string }[] = [] + for (const event of snapshot.events) { + if (event.type === 'user/message') messages.push(event.data) + else if (event.type === 'assistant/message' + || event.type === 'tool/result') messages.push(event.data.message) + } expect(messages.map(message => message.id)).toEqual([ `legacy-message:${id}:1`, `legacy-message:${id}:3`, `legacy-message:${id}:5`, - `legacy-message:${id}:6`, `legacy-message:${id}:5`, ]) expect(messages.every(message => Object.isFrozen(message))).toBe(true) - const resumed = new Session(id, snapshot.events, snapshot.meta) + const resumed = Session.create(id, snapshot.events, snapshot.meta) expect(resumed.deriveMessages().map(message => message.id)).toEqual([ `legacy-message:${id}:1`, `legacy-message:${id}:3`, `legacy-message:${id}:5`, - `legacy-message:${id}:6`, ]) } + + const replacementSuffix = await ctx.sessionPersistence.readFrom(id, 6) + expect(replacementSuffix.events[0]).toMatchObject({ + type: 'tool/result', + seq: 6, + data: { message: { id: `legacy-message:${id}:5` } }, + }) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('loads pre-react-loop session logs into resumable current sessions', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('pre-react-loop-load') + const log = preReactLoopLog() + const legacySteering = log[3] as unknown as { data: { message: { id: string } } } + await ctx.sessionPersistence.create(meta(id, WORK)) + await ctx.sessionPersistence.append(id, log) + + const snapshots = [ + await ctx.sessionPersistence.inspect(id), + await ctx.sessionPersistence.readFrom(id, 0), + await ctx.sessionPersistence.load(id), + ] + for (const snapshot of snapshots) { + expect(snapshot.events.some(event => (event.type as string) === 'steering/message')).toBe(false) + expect(snapshot.events.filter(event => event.type === 'turn/start').map(event => event.data)) + .toEqual([ + { turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }, { turn: 6 }, { turn: 7 }, + ]) + expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([ + { turn: 1, reason: { kind: 'completed' } }, + { + turn: 2, + reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } }, + }, + { turn: 3, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }, + { turn: 4, reason: { kind: 'aborted', reason: { kind: 'disposed' } } }, + { + turn: 5, + reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } }, + }, + { + turn: 6, + reason: { + kind: 'error', + error: { + message: 'old detailed provider failure', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1000, + requestId: 'request-1', + }, + }, + }, + { + turn: 7, + reason: { kind: 'error', error: { message: 'old coded error', code: 'CODED' } }, + }, + ]) + + const resumed = Session.create(id, snapshot.events, snapshot.meta) + expect(resumed.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'old prompt' }], + [{ type: 'text', text: 'old steering' }], + ]) + } + + const suffix = await ctx.sessionPersistence.readFrom(id, 3) + expect(suffix.events[0]).toMatchObject({ + type: 'user/message', + seq: 3, + data: { id: legacySteering.data.message.id }, + }) + expect(suffix.events.filter(event => event.type === 'turn/end') + .every(event => !Object.hasOwn(event.data, 'step'))).toBe(true) + + const flatId = SessionId('pre-react-loop-flat-steering') + await ctx.sessionPersistence.create(meta(flatId, WORK)) + await ctx.sessionPersistence.append(flatId, [{ + type: 'steering/message', + seq: 0, + time: 1, + data: { + turn: 1, + content: [{ type: 'text', text: 'flat steering' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + } as unknown as SessionEvent]) + expect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({ + type: 'user/message', + data: { + id: `legacy-message:${flatId}:0`, + role: 'user', + content: [{ type: 'text', text: 'flat steering' }], + }, + }) + + const extendedId = SessionId('current-extended-turn-end') + await ctx.sessionPersistence.create(meta(extendedId, WORK)) + await ctx.sessionPersistence.append(extendedId, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { + type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'extension-reason' } }, + } as unknown as SessionEvent, + ]) + expect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'extension-reason' } }, + }) } finally { await fiber.dispose() await fix.cleanup() @@ -417,7 +603,96 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await expect(ctx.sessionPersistence.load(id)) .rejects.toThrow('message must have role "user"') - for (const type of ['tool/result', 'steering/message'] as const) { + const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [ + { + id: 'invalid-old-turn-start', + event: { + type: 'turn/start', seq: 0, time: 1, + data: { turn: 1, trigger: null }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/start', + }, + { + id: 'invalid-old-steering', + event: { + type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', + data: { turn: 1, content: [], source: { kind: 'user' }, extra: true }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop steering/message', + }, + { + id: 'invalid-old-steering-data', + event: { + type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', data: null, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop steering/message', + }, + { + id: 'invalid-old-turn-end', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'completed', extra: true } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'invalid-old-turn-end-reason', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: null }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'unsupported-intermediate-turn-end-step', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, step: 1, reason: { kind: 'completed' } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'invalid-old-turn-end-aborted', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'aborted', extra: true } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'invalid-old-turn-end-disposed', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'disposed', extra: true } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'invalid-old-turn-end-error-step', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'error', step: -1, message: 'bad step' } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + { + id: 'invalid-old-turn-end-error-code', + event: { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'error', step: 0, message: 'bad code', code: 1 } }, + } as unknown as SessionEvent, + message: 'malformed pre-react-loop turn/end', + }, + ] + for (const malformed of malformedLegacy) { + const malformedId = SessionId(malformed.id) + await ctx.sessionPersistence.create(meta(malformedId, WORK)) + await ctx.sessionPersistence.append(malformedId, [malformed.event]) + await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message) + await expect(ctx.sessionPersistence.readFrom(malformedId, 0)).rejects.toThrow(malformed.message) + } + + for (const type of ['tool/result'] as const) { const malformedId = SessionId(`invalid-${type}`) await ctx.sessionPersistence.create(meta(malformedId, WORK)) await ctx.sessionPersistence.append(malformedId, [{ @@ -441,6 +716,22 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } as unknown as SessionEvent]) await expect(ctx.sessionPersistence.inspect(pluginId)) .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + await expect(ctx.sessionPersistence.readFrom(pluginId, 0)) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + + for (const type of ['user/message', 'assistant/message'] as const) { + const missingContentId = SessionId(`invalid-${type}-without-content`) + await ctx.sessionPersistence.create(meta(missingContentId, WORK)) + await ctx.sessionPersistence.append(missingContentId, [{ + type, + seq: 0, + time: 1, + surfaceOp: 'append', + data: {}, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.readFrom(missingContentId, 0)) + .rejects.toThrow('lacks an identified message') + } } finally { await fiber.dispose() await fix.cleanup() @@ -458,7 +749,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const p = ctx.sessionPersistence.append(m.id, events) // Mutate the caller's array AND an event object after the call but before // the queued op runs: the snapshot taken at call time must shield the copy. - events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }) + events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2 } }) if (userMsg?.type === 'user/message') { (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }] } @@ -516,7 +807,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) await second.ctx.sessions.flush(s2) // let onCreated adopt - s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + s2.append('turn/start', { turn: 2 }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) await second.ctx.sessions.flush(s2) @@ -538,7 +829,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -562,7 +853,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) const fiber = await fix.mount(ctx) const session = await liveSessionInFiber(ctx, 'drain', WORK) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -590,7 +881,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { // Backend instance 1 materializes the session. const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -602,7 +893,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // materialized prefix, then persist another turn rather than rejecting it as a collision. await backend1.dispose() await fix.mount(ctx) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -625,7 +916,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { // Instance 1 flushes turn 1. const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(session) @@ -633,7 +924,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // flushing turn 2: it is now ONLY in the live session's events; the new // backend never buffered it via session/event. await backend1.dispose() - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the @@ -656,7 +947,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const session = await liveSessionInFiber(ctx, 'hmr-open', WORK) try { const first = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) await ctx.sessions.flush(session) @@ -699,7 +990,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) - s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s2.append('turn/start', { turn: 1 }) await expect(second.ctx.sessions.flush(s2)) .rejects.toThrow(/already has a persisted log|id collision/) } finally { @@ -726,7 +1017,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(ctx.sessions.flush(reuse)).resolves.toBe(true) - reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + reuse.append('turn/start', { turn: 1 }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(reuse) const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) @@ -747,7 +1038,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }, { inject: ['sessions'] })) await ctx.sessions.flush(first) // Append a turn but do NOT flush — events sit in the write-behind buffer. - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await firstFiber.dispose() @@ -774,7 +1065,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -837,6 +1128,28 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a live session whose complete seed matches loaded ownerless state claims it without appending', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('claim-exact') + const completeSeed = [ + ...oneTurnLog(), + { type: 'session/end-seed', seq: 6, time: 7, data: {} }, + ] as SessionEvent[] + await ctx.sessionPersistence.create(meta(id, WORK)) + await ctx.sessionPersistence.append(id, completeSeed) + const { events } = await ctx.sessionPersistence.load(id) + + const live = ctx.sessions.create(id, { seed: events, meta: { cwd: WORK } }) + await expect(ctx.sessions.flush(live)).resolves.toBe(true) + expect((await ctx.sessionPersistence.load(id)).events).toEqual(events) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) @@ -853,7 +1166,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const contFiber = await ctx.plugin(Object.assign((inner: Context) => { cont = inner.sessions.create(SessionId('claim'), { seed: [ ...events, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ], meta: { cwd: WORK, createdAt: 2000 } }) }, { inject: ['sessions'] })) @@ -945,7 +1258,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ]) const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append')) @@ -1045,7 +1358,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1078,7 +1391,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced) // A second turn whose real events are durable but never closed (open turn). await first.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ]) } finally { @@ -1105,7 +1418,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // The repair is durable: the next append continues at the balanced length // (seq 10) and a reload round-trips identically. await second.ctx.sessionPersistence.append(SessionId('torn'), [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn')) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index eb80683431..8fac982591 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, isJsonValue } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -12,6 +12,11 @@ import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-c /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }> +/** Test-store revision that changes for any metadata or event mutation. */ +function memoryRevision(entry: { meta: SessionHeader; events: SessionEvent[] }): SessionPersistenceRevision { + return SessionPersistenceRevision(JSON.stringify(entry)) +} + /** An obsolete event fixture that emulates an untyped pre-change producer. */ function legacyHeaderDelta(seq = 0): SessionEvent { return { @@ -91,12 +96,17 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.append(id, events) } + override prepare(id: SessionId, signal?: AbortSignal): ReturnType<PersistenceCoordinator['prepare']> { + return this.coordinator.prepare(id, signal) + } + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.load(id) + return this.coordinator.load(id).then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) } inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.inspect(id, signal) + .then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) } readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { @@ -109,7 +119,16 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> { const entry = this.store.get(id) if (!entry) return undefined - return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } + return { + meta: structuredClone(entry.meta), + events: structuredClone(entry.events), + revision: memoryRevision(entry), + } + } + + async readStoredRevision(id: SessionId): Promise<SessionPersistenceRevision | undefined> { + const entry = this.store.get(id) + return entry === undefined ? undefined : memoryRevision(entry) } async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise<void> { @@ -146,7 +165,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend signal?.throwIfAborted() return [...this.store.values()].map(entry => ({ header: structuredClone(entry.meta), - revision: SessionPersistenceRevision(`events:${entry.events.length}`), + revision: memoryRevision(entry), })) } } @@ -162,18 +181,29 @@ class ControlledBackend implements PersistenceBackend<never> { beforeAppend?: (attempt: number) => Promise<void> beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void> /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ - seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredPrefix<never> | undefined> + seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredSuffix | undefined> - loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> { + loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> { if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') return this.seekHook(id, fromSeq, signal) } async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> { - await this.beforeLoadStored?.(++this.loadAttempts, signal) + const attempt = ++this.loadAttempts + await this.beforeLoadStored?.(attempt, signal) const entry = this.store.get(id) if (entry === undefined) return undefined - return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } + return { + meta: structuredClone(entry.meta), + events: structuredClone(entry.events), + revision: memoryRevision(entry), + } + } + + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<SessionPersistenceRevision | undefined> { + signal?.throwIfAborted() + const entry = this.store.get(id) + return entry === undefined ? undefined : memoryRevision(entry) } async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise<void> { @@ -187,8 +217,10 @@ class ControlledBackend implements PersistenceBackend<never> { } } - async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> { + async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise<void> { this.repairAttempts += 1 + const entry = this.store.get(m.id) + if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) } async list(): Promise<SessionHeader[]> { @@ -237,7 +269,7 @@ describe('PersistenceCoordinator eager writes', () => { try { const session = ctx.sessions.create(SessionId('eager-follow-up')) await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -272,7 +304,7 @@ describe('PersistenceCoordinator eager writes', () => { try { const session = ctx.sessions.create(SessionId('eager-flush-retry')) await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) @@ -302,7 +334,7 @@ describe('PersistenceCoordinator stored identity', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }], }) let coordinator!: PersistenceCoordinator<never> @@ -329,7 +361,7 @@ describe('PersistenceCoordinator stored identity', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } backend.store.set(id, { meta: header, events: [start] }) const loadGate = Promise.withResolvers<boolean>() @@ -345,7 +377,7 @@ describe('PersistenceCoordinator stored identity', () => { await expect(ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(id, { seed: [start], meta: header }) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) expect(ctx.sessions.get(id)).toBeUndefined() loadGate.resolve(true) @@ -362,6 +394,658 @@ describe('PersistenceCoordinator stored identity', () => { }) }) +describe('PersistenceCoordinator session preparations', () => { + it.each([0, 1.5])('rejects invalid preparation cache capacity %s', (capacity) => { + const ctx = new Context() + const backend = new ControlledBackend() + + expect(() => new PersistenceCoordinator(ctx, backend, { + preparedSessionCacheSize: capacity, + })).toThrow(/positive safe integer/) + }) + + it('retries invalidated prepare and load reservations', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const prepareId = SessionId('prepare-reservation-retry') + const loadId = SessionId('load-reservation-retry') + backend.store.set(prepareId, { meta: meta(prepareId), events: oneTurnLog() }) + backend.store.set(loadId, { meta: meta(loadId), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const preparations = (coordinator as unknown as { + preparations: { reserve: (...args: unknown[]) => Promise<unknown> } + }).preparations + const reserve = vi.spyOn(preparations, 'reserve') + + try { + reserve.mockResolvedValueOnce(undefined) + const preparation = await coordinator.prepare(prepareId) + preparation[Symbol.dispose]() + + reserve.mockResolvedValueOnce(undefined) + await expect(coordinator.load(loadId)).resolves.toMatchObject({ meta: { id: loadId } }) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('prefers a session that becomes live across preparation reads', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const prepareId = SessionId('prepare-became-live') + const loadId = SessionId('load-became-live') + const inspectId = SessionId('inspect-became-live') + const validatedInspectId = SessionId('validated-inspect-became-live') + const failedInspectId = SessionId('failed-inspect-became-live') + for (const id of [prepareId, loadId, inspectId, validatedInspectId]) { + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + } + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const prepareLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) + const prepareGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(prepareLive) + await expect(coordinator.prepare(prepareId)).rejects.toThrow(/while it is live/) + prepareGet.mockRestore() + + const loadLive = Session.create(loadId, oneTurnLog(), meta(loadId)) + const loadGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(loadLive) + await expect(coordinator.load(loadId)).resolves.toMatchObject({ meta: { id: loadId } }) + loadGet.mockRestore() + + const inspectLive = Session.create(inspectId, oneTurnLog(), meta(inspectId)) + const inspectGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(inspectLive) + await expect(coordinator.inspect(inspectId)).resolves.toMatchObject({ meta: { id: inspectId } }) + inspectGet.mockRestore() + + const validatedInspectLive = Session.create(validatedInspectId, oneTurnLog(), meta(validatedInspectId)) + const validatedInspectGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(validatedInspectLive) + await expect(coordinator.inspect(validatedInspectId)) + .resolves.toMatchObject({ meta: { id: validatedInspectId } }) + validatedInspectGet.mockRestore() + + const failedInspectLive = Session.create(failedInspectId, oneTurnLog(), meta(failedInspectId)) + backend.beforeLoadStored = () => Promise.reject(new Error('load failed')) + const failedInspectGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(failedInspectLive) + await expect(coordinator.inspect(failedInspectId)) + .resolves.toMatchObject({ meta: { id: failedInspectId } }) + failedInspectGet.mockRestore() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('rejects a prepared commit when durable state already has a live owner', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepared-commit-live-owner') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const owner = Session.create(id, oneTurnLog(), meta(id)) + const states = (coordinator as unknown as { + states: Map<SessionId, { + meta: SessionHeader + cursor: number + materialized: boolean + owner?: Session + }> + }).states + states.set(id, { + meta: owner.header, + cursor: oneTurnLog().length, + materialized: true, + owner, + }) + + try { + await expect(coordinator.prepare(id)).rejects.toThrow(/live persistence owner/) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('rejects publication after a preparation state no longer matches', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepared-publication-mismatch') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const preparation = await coordinator.prepare(id) + const preparations = (coordinator as unknown as { + preparations: { + reservationFor: (session: Session) => { state: { cursor: number } } | undefined + } + }).preparations + const reservation = preparations.reservationFor(preparation.session) + if (reservation === undefined) throw new Error('test preparation must stay reserved') + reservation.state.cursor += 1 + const detach = ctx.sessions.enter(preparation.session) + + try { + expect(() => { ctx.sessions.announce(preparation.session) }).toThrow(/no longer matches/) + } finally { + detach() + preparation[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('observes a restored suffix initialization failure', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepared-suffix-init-failure') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const preparation = await coordinator.prepare(id) + const internals = coordinator as unknown as { + preparations: { reservationFor: (session: Session) => object | undefined } + attachPrepared: (session: Session, reservation: object) => { init: Promise<void> } + } + const reservation = internals.preparations.reservationFor(preparation.session) + if (reservation === undefined) throw new Error('test preparation must stay reserved') + const failure = new Error('restored suffix append failed') + backend.beforeAppend = () => Promise.reject(failure) + preparation.session.append('turn/start', { turn: 2 }) + + try { + const live = internals.attachPrepared(preparation.session, reservation) + await expect(live.init).rejects.toBe(failure) + } finally { + preparation[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('reuses the exact Session from inspect through repeated unpublished prepare calls', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('inspect-prepare-reuse') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let first: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + let second: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + const inspected = await coordinator.inspect(id) + first = await coordinator.prepare(id) + + expect(backend.loadAttempts).toBe(1) + expect(first.session.events[0]).toBe(inspected.events[0]) + + first[Symbol.dispose]() + second = await coordinator.prepare(id) + expect(second.session).toBe(first.session) + expect(backend.loadAttempts).toBe(1) + } finally { + second?.[Symbol.dispose]() + first?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('reloads a cached inspection after the durable revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('inspect-revision-refresh') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const first = await coordinator.inspect(id) + backend.store.get(id)!.events.push( + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ) + + const refreshed = await coordinator.inspect(id) + expect(refreshed.events).toHaveLength(8) + expect(refreshed.events[0]).not.toBe(first.events[0]) + expect(backend.loadAttempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('does not restore from a cached inspection after the durable revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepare-revision-refresh') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let preparation: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + const inspected = await coordinator.inspect(id) + backend.store.get(id)!.events.push( + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ) + + preparation = await coordinator.prepare(id) + expect(preparation.session.events).toHaveLength(9) + expect(preparation.session.events[0]).not.toBe(inspected.events[0]) + expect(backend.loadAttempts).toBe(2) + } finally { + preparation?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retains a reserved preparation when inspection observes a newer external revision', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('reserved-inspect-revision-race') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let preparation: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + let detach: (() => void) | undefined + + try { + const cached = await coordinator.inspect(id) + preparation = await coordinator.prepare(id) + backend.store.get(id)!.events.push( + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ) + + await expect(coordinator.inspect(id)).resolves.toBe(cached) + const preparations = (coordinator as unknown as { + preparations: { reservationFor: (session: Session) => object | undefined } + }).preparations + expect(preparations.reservationFor(preparation.session)).toBeDefined() + + detach = ctx.sessions.enter(preparation.session) + expect(() => { ctx.sessions.announce(preparation!.session) }).not.toThrow() + expect(preparations.reservationFor(preparation.session)).toBeUndefined() + } finally { + detach?.() + preparation?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('queues a same-tick cold append behind preparation readiness', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('inspect-cold-append-race') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const inspection = coordinator.inspect(id) + const append = coordinator.append(id, [{ + type: 'turn/start', + seq: oneTurnLog().length, + time: 7, + data: { turn: 2 }, + }]) + + await expect(inspection).resolves.toMatchObject({ + meta: { id }, + events: [...oneTurnLog(), { seq: 6 }, { seq: 7 }], + }) + await expect(append).resolves.toBeUndefined() + expect(backend.loadAttempts).toBe(2) + expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('allows a same-tick cold append to start before inspection', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('cold-append-inspect-race') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const append = coordinator.append(id, [{ + type: 'turn/start', + seq: oneTurnLog().length, + time: 7, + data: { turn: 2 }, + }]) + const inspection = coordinator.inspect(id) + + await expect(append).resolves.toBeUndefined() + await expect(inspection).resolves.toMatchObject({ + meta: { id }, + events: [...oneTurnLog(), { seq: 6 }, { seq: 7 }], + }) + expect(backend.loadAttempts).toBe(2) + expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries cold append adoption when the prepared revision becomes stale', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('append-adoption-revision-refresh') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + const readStoredRevision = backend.readStoredRevision.bind(backend) + vi.spyOn(backend, 'readStoredRevision') + .mockResolvedValueOnce(SessionPersistenceRevision('stale-revision')) + .mockImplementation(readStoredRevision) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.append(id, [{ + type: 'turn/start', + seq: oneTurnLog().length, + time: 7, + data: { turn: 2 }, + }]) + + expect(backend.loadAttempts).toBe(2) + expect(backend.appendAttempts).toBe(1) + expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('inspects an open live turn without balancing it', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('inspect-live-open-turn')) + session.append('turn/start', { turn: 1 }) + + const inspected = await coordinator.inspect(session.id) + expect(inspected.events).toBe(session.events) + expect(inspected.events.map(event => event.type)).toEqual(['turn/start']) + await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('keeps synthetic recovery in memory during inspect and commits it only once on prepare', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('inspect-repair-commit') + backend.store.set(id, { + meta: meta(id), + events: [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1 }, + }], + }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let first: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + let second: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + const inspected = await coordinator.inspect(id) + expect(inspected.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(backend.store.get(id)?.events.map(event => event.type)).toEqual(['turn/start']) + expect(backend.repairAttempts).toBe(0) + + first = await coordinator.prepare(id) + expect(backend.repairAttempts).toBe(1) + expect(backend.store.get(id)?.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + first[Symbol.dispose]() + + second = await coordinator.prepare(id) + expect(second.session).toBe(first.session) + expect(backend.loadAttempts).toBe(2) + expect(backend.repairAttempts).toBe(1) + } finally { + second?.[Symbol.dispose]() + first?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('reloads the committed graph when another writer appends after repair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('repair-external-append') + backend.store.set(id, { + meta: meta(id), + events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + }) + const commitRepair = backend.commitRepair.bind(backend) + vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { + await commitRepair(header, tornMarker, closers) + const entry = backend.store.get(id) + if (entry === undefined) throw new Error('test repair must keep storage materialized') + const seq = entry.events.length + entry.events.push( + { type: 'turn/start', seq, time: 3, data: { turn: 2 } }, + { type: 'turn/end', seq: seq + 1, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, + ) + }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let preparation: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + preparation = await coordinator.prepare(id) + + expect(preparation.session.events.map(event => event.type)).toEqual([ + 'turn/start', + 'turn/end', + 'turn/start', + 'turn/end', + 'session/end-seed', + ]) + expect(backend.loadAttempts).toBe(2) + expect(backend.repairAttempts).toBe(1) + } finally { + preparation?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('rejects preparation when storage disappears during the post-repair reload', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('repair-disappeared') + backend.store.set(id, { + meta: meta(id), + events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + }) + const commitRepair = backend.commitRepair.bind(backend) + vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { + await commitRepair(header, tornMarker, closers) + backend.store.delete(id) + }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(coordinator.prepare(id)).rejects.toThrow(/not found/) + expect(backend.repairAttempts).toBe(1) + expect(backend.loadAttempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('waits for an existing reservation and reuses it after release', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepare-reservation-wait') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let first: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + let second: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + first = await coordinator.prepare(id) + let secondResolved = false + const waiting = coordinator.prepare(id).then((preparation) => { + secondResolved = true + return preparation + }) + await Promise.resolve() + expect(secondResolved).toBe(false) + + first[Symbol.dispose]() + second = await waiting + expect(second.session).toBe(first.session) + expect(backend.loadAttempts).toBe(1) + } finally { + second?.[Symbol.dispose]() + first?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('evicts only ready preparations by LRU capacity', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const firstId = SessionId('preparation-lru-first') + const secondId = SessionId('preparation-lru-second') + backend.store.set(firstId, { meta: meta(firstId), events: oneTurnLog() }) + backend.store.set(secondId, { meta: meta(secondId), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend, { preparedSessionCacheSize: 1 }) + }, { inject: ['sessions'] })) + + try { + await coordinator.inspect(firstId) + await coordinator.inspect(secondId) + await coordinator.inspect(firstId) + expect(backend.loadAttempts).toBe(3) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('rejects append while an unpublished preparation owns the persisted cursor', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('reserved-append') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let preparation: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + preparation = await coordinator.prepare(id) + await expect(coordinator.append(id, [{ + type: 'turn/start', + seq: oneTurnLog().length, + time: 7, + data: { turn: 2 }, + }])).rejects.toThrow(/persisted preparation is reserved/) + } finally { + preparation?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator observation cancellation', () => { it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => { const ctx = new Context() @@ -400,7 +1084,7 @@ describe('PersistenceCoordinator observation cancellation', () => { await expect(prior).resolves.toMatchObject({ meta: { id } }) await observedAbort await expect(subsequent).resolves.toMatchObject({ meta: { id } }) - expect(backend.loadAttempts).toBe(2) + expect(backend.loadAttempts).toBe(1) await vi.waitFor(() => { expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0) }) @@ -411,54 +1095,66 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) - it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => { + it('keeps a shared cold read alive when its creating inspect is cancelled', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const backend = new ControlledBackend() - const id = SessionId('active-inspect-cancellation') + const id = SessionId('creating-inspect-cancellation') backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const cleanupGate = Promise.withResolvers<boolean>() - let cleanupComplete = false - backend.beforeLoadStored = async (_attempt, signal) => { - await new Promise<void>((resolve) => { - signal?.addEventListener('abort', () => { - void cleanupGate.promise.then(() => { - cleanupComplete = true - resolve() - }) - }, { once: true }) - }) - throw new Error('backend cancellation after cleanup') + const loadGate = Promise.withResolvers<boolean>() + backend.beforeLoadStored = () => loadGate.promise.then(() => undefined) + let coordinator!: PersistenceCoordinator<never> + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + let prepared: Awaited<ReturnType<typeof coordinator.prepare>> | undefined + + try { + const controller = new AbortController() + const reason = new Error('creating inspect cancelled') + const inspection = coordinator.inspect(id, controller.signal) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + const reservation = coordinator.prepare(id) + + controller.abort(reason) + await expect(inspection).rejects.toBe(reason) + loadGate.resolve(true) + prepared = await reservation + expect(prepared.session.id).toBe(id) + expect(backend.loadAttempts).toBe(1) + } finally { + loadGate.resolve(true) + prepared?.[Symbol.dispose]() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('preserves inspect cancellation when the session concurrently becomes live', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('cancelled-inspect-became-live') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + const controller = new AbortController() + const reason = new Error('inspect cancelled while publishing') + backend.beforeLoadStored = async () => { + controller.abort(reason) + throw new Error('load stopped after cancellation') } let coordinator!: PersistenceCoordinator<never> const fiber = await ctx.plugin(Object.assign((inner: Context) => { coordinator = new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) + const live = Session.create(id, oneTurnLog(), meta(id)) + const get = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(live) try { - const controller = new AbortController() - const reason = new Error('active inspect cancelled') - const pending = coordinator.inspect(id, controller.signal) - let observedReason: unknown - const observed = pending.catch((error: unknown) => { - observedReason = error - }) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - - controller.abort(reason) - await Promise.resolve() - - expect(observedReason).toBeUndefined() - expect(cleanupComplete).toBe(false) - cleanupGate.resolve(true) - await observed - expect(cleanupComplete).toBe(true) - expect(observedReason).toBe(reason) - const backendFailure = new Error('later inspection failure') - backend.beforeLoadStored = () => Promise.reject(backendFailure) - await expect(coordinator.inspect(id)).rejects.toBe(backendFailure) + await expect(coordinator.inspect(id, controller.signal)).rejects.toBe(reason) } finally { - cleanupGate.resolve(true) + get.mockRestore() await fiber.dispose() await ctx.fiber.dispose() } @@ -534,12 +1230,13 @@ describe('PersistenceCoordinator observation cancellation', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(id) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Dispose the session so retirement starts; its append is gated, so the // retirement promise stays pending in the coordinator. await sessionFiber.dispose() await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) }) + const baselineLoads = backend.loadAttempts const controller = new AbortController() const reason = new Error('inspect cancelled during retirement') @@ -552,7 +1249,7 @@ describe('PersistenceCoordinator observation cancellation', () => { // backend read. controller.abort(reason) await vi.waitFor(() => { expect(observedReason).toBe(reason) }) - expect(backend.loadAttempts).toBe(0) + expect(backend.loadAttempts).toBe(baselineLoads) appendGate.resolve(true) await observed @@ -621,15 +1318,17 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) - // Occupy the per-id serialize chain with a gated read: everything the - // two retirements queue stays pending behind it. (Attempt counting - // starts here — an absent beforeLoadStored short-circuits the optional - // call without evaluating its ++ argument.) - backend.beforeLoadStored = async (attempt) => { - if (attempt === 1) await readGate.promise + // Occupy the per-id serialize chain with a gated physical read: + // inspect() correctly borrows the still-live Session without entering + // the backend chain, while both retirements must queue behind readFrom(). + const readEntered = Promise.withResolvers<undefined>() + backend.seekHook = async () => { + readEntered.resolve(undefined) + await readGate.promise + return undefined } - const parked = coordinator.inspect(id).catch((error: unknown) => error) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error) + await readEntered.promise // First retirement queues behind the gate and stays pending. await firstFiber.dispose() @@ -677,7 +1376,7 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() @@ -717,7 +1416,7 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() @@ -730,7 +1429,7 @@ describe('PersistenceCoordinator retirement', () => { await expect(ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(id) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) loadGate.resolve(true) await expect(coldLoad).resolves.toMatchObject({ @@ -776,7 +1475,7 @@ describe('PersistenceCoordinator retirement', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) const secondAppend = coordinator.append(id, [{ type: 'turn/end', @@ -824,7 +1523,7 @@ describe('PersistenceCoordinator retirement', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('retry-retirement')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await sessionFiber.dispose() @@ -868,7 +1567,7 @@ describe('PersistenceCoordinator retirement', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('inflight-retirement')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await sessionFiber.dispose() await vi.waitFor(() => { @@ -916,7 +1615,7 @@ describe('PersistenceCoordinator retirement', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) @@ -937,6 +1636,50 @@ describe('PersistenceCoordinator retirement', () => { }) describe('SessionPersistence service registration', () => { + it('provides a cancellation-aware default preparation for simple backends', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence) + const m = meta('default-preparation') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const defaultPrepare = SessionPersistence.prototype.prepare.bind(ctx.sessionPersistence) + + const preparation = await defaultPrepare(m.id) + expect(preparation.session.header).toEqual(m) + preparation[Symbol.dispose]() + + const preAborted = new AbortController() + const preAbortReason = new Error('pre-aborted preparation') + preAborted.abort(preAbortReason) + await expect(defaultPrepare(m.id, preAborted.signal)) + .rejects.toBe(preAbortReason) + + const postAborted = new AbortController() + const postAbortReason = new Error('post-load preparation abort') + const originalLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + ctx.sessionPersistence.load = async (id) => { + const loaded = await originalLoad(id) + postAborted.abort(postAbortReason) + return loaded + } + await expect(defaultPrepare(m.id, postAborted.signal)) + .rejects.toBe(postAbortReason) + + await fiber.dispose() + }) + + it('requires SessionStore for the default preparation', async () => { + const id = SessionId('default-preparation-without-store') + const persistence = { + ctx: new Context(), + load: () => Promise.resolve({ meta: meta(id), events: oneTurnLog() }), + } as unknown as SessionPersistence + + await expect(SessionPersistence.prototype.prepare.call(persistence, id)) + .rejects.toThrow(/SessionStore is not configured/) + }) + it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -1053,7 +1796,7 @@ describe('SessionPersistence service registration', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId(`disposed-${index}`)) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(session) await sessionFiber.dispose() diff --git a/packages/session-persistence/session-persistence/tests/preparations.spec.ts b/packages/session-persistence/session-persistence/tests/preparations.spec.ts new file mode 100644 index 0000000000..5e12f29a79 --- /dev/null +++ b/packages/session-persistence/session-persistence/tests/preparations.spec.ts @@ -0,0 +1,360 @@ +/** Unit coverage for unpublished Session preparation ownership and sharing. */ + +import { describe, expect, it, vi } from 'vitest' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts' + +interface PreparedSource { + readonly session: Session + readonly label: string +} + +function prepared(label: string): PreparedSource { + return { session: Session.create(SessionId(label)), label } +} + +function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> { + return Promise.resolve({ source, state: source.label }) +} + +describe('SessionPreparations inspection', () => { + it('shares in-flight and ready sources, then invalidates them', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(2) + const id = SessionId('shared-inspection') + const gate = Promise.withResolvers<PreparedSource>() + const load = vi.fn(() => gate.promise) + const first = preparations.inspect(id, load) + const second = preparations.inspect(id, load, new AbortController().signal) + const source = prepared(id) + + expect(preparations.has(id)).toBe(true) + gate.resolve(source) + await expect(first).resolves.toBe(source) + await expect(second).resolves.toBe(source) + await expect(preparations.inspect(id, load)).resolves.toBe(source) + expect(load).toHaveBeenCalledOnce() + + preparations.invalidate(id) + preparations.invalidate(id) + expect(preparations.has(id)).toBe(false) + }) + + it('keeps a shared load alive when its first observer cancels', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('cancelled-first-observer') + const gate = Promise.withResolvers<PreparedSource>() + const load = vi.fn(() => gate.promise) + const controller = new AbortController() + const reason = new Error('first observer cancelled') + const first = preparations.inspect(id, load, controller.signal) + const joined = preparations.inspect(id, load) + + controller.abort(reason) + await expect(first).rejects.toBe(reason) + const source = prepared(id) + gate.resolve(source) + await expect(joined).resolves.toBe(source) + await expect(preparations.inspect(id, load)).resolves.toBe(source) + expect(load).toHaveBeenCalledOnce() + }) + + it('evicts completed loads whose observers cancelled before readiness', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const firstId = SessionId('cancelled-ready-first') + const secondId = SessionId('cancelled-ready-second') + const firstGate = Promise.withResolvers<PreparedSource>() + const secondGate = Promise.withResolvers<PreparedSource>() + const firstController = new AbortController() + const secondController = new AbortController() + const first = preparations.inspect(firstId, () => firstGate.promise, firstController.signal) + const second = preparations.inspect(secondId, () => secondGate.promise, secondController.signal) + + firstController.abort(new Error('first observer cancelled')) + secondController.abort(new Error('second observer cancelled')) + await expect(first).rejects.toThrow('first observer cancelled') + await expect(second).rejects.toThrow('second observer cancelled') + + firstGate.resolve(prepared(firstId)) + await firstGate.promise + secondGate.resolve(prepared(secondId)) + await secondGate.promise + await Promise.resolve() + + expect(preparations.has(firstId)).toBe(false) + expect(preparations.has(secondId)).toBe(true) + }) + + it('removes failed and invalidated in-flight loads without changing their observers', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const failedId = SessionId('failed-inspection') + const failure = new Error('load failed') + await expect(preparations.inspect(failedId, () => Promise.reject(failure))).rejects.toBe(failure) + expect(preparations.has(failedId)).toBe(false) + + const invalidatedId = SessionId('invalidated-inspection') + const gate = Promise.withResolvers<PreparedSource>() + const inspection = preparations.inspect(invalidatedId, () => gate.promise) + preparations.invalidate(invalidatedId) + const source = prepared(invalidatedId) + gate.resolve(source) + await expect(inspection).resolves.toBe(source) + expect(preparations.has(invalidatedId)).toBe(false) + + const rejectedId = SessionId('invalidated-rejection') + const rejectedGate = Promise.withResolvers<PreparedSource>() + const rejected = preparations.inspect(rejectedId, () => rejectedGate.promise) + preparations.invalidate(rejectedId) + rejectedGate.reject(failure) + await expect(rejected).rejects.toBe(failure) + }) + + it('removes a load that throws before returning its promise', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('synchronous-load-failure') + const failure = new Error('synchronous load failure') + + await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure) + expect(preparations.has(id)).toBe(false) + }) + + it('evicts ready entries while leaving reserved entries alone', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const reservedA = await preparations.reserve( + SessionId('reserved-a'), + () => Promise.resolve(prepared('reserved-a')), + committed, + ) + const reservedB = await preparations.reserve( + SessionId('reserved-b'), + () => Promise.resolve(prepared('reserved-b')), + committed, + ) + expect(reservedA).toBeDefined() + expect(reservedB).toBeDefined() + + await preparations.inspect(SessionId('ready-c'), () => Promise.resolve(prepared('ready-c'))) + preparations.release(reservedA!, true) + expect(preparations.has(SessionId('reserved-b'))).toBe(true) + expect(preparations.has(SessionId('ready-c'))).toBe(false) + expect(preparations.has(SessionId('reserved-a'))).toBe(true) + + preparations.discard(reservedB!) + preparations.invalidate(SessionId('reserved-a')) + }) + + it('discards only the exact ready source and retains exclusive reservations', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const ready = prepared('discard-ready') + expect(preparations.discardReady(ready.session.id, ready)).toBe('missing') + await preparations.inspect(ready.session.id, () => Promise.resolve(ready)) + expect(preparations.discardReady(ready.session.id, prepared('different'))).toBe('missing') + expect(preparations.discardReady(ready.session.id, ready)).toBe('discarded') + + const reserved = await preparations.reserve( + ready.session.id, + () => Promise.resolve(ready), + committed, + ) + expect(preparations.discardReady(ready.session.id, ready)).toBe('retained') + preparations.release(reserved!, false) + }) +}) + +describe('SessionPreparations reservation', () => { + it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(2) + const id = SessionId('reservation-wait') + const source = prepared(id) + const first = await preparations.reserve(id, () => Promise.resolve(source), committed) + expect(first).toBeDefined() + expect(preparations.reservationFor(source.session)).toBe(first) + expect(() => preparations.reservationFor(Session.create(id))).toThrow(/cannot publish/) + expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/) + + let secondSettled = false + const secondPromise = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed) + .then((reservation) => { + secondSettled = true + return reservation + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(secondSettled).toBe(false) + + preparations.release(first!, true) + const second = await secondPromise + expect(second?.source).toBe(source) + preparations.attach(second!) + expect(preparations.reservationFor(source.session)).toBeUndefined() + expect(() => { preparations.attach(second!) }).toThrow(/no longer reserved/) + preparations.discard(second!) + preparations.release(second!, true) + expect(() => { preparations.assertWritable(id) }).not.toThrow() + }) + + it('supports abortable reservation waits without cancelling the held reservation', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('abortable-reservation-wait') + const first = await preparations.reserve(id, () => Promise.resolve(prepared(id)), committed) + const controller = new AbortController() + const reason = { kind: 'cancelled' } + const waiting = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed, controller.signal) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + controller.abort(reason) + await expect(waiting).rejects.toBe(reason) + expect(preparations.reservationFor(first!.source.session)).toBe(first) + preparations.release(first!, false) + expect(preparations.has(id)).toBe(false) + }) + + it('removes a failed commit and wakes another waiter as invalidated', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('failed-commit') + const commitStarted = Promise.withResolvers<undefined>() + const commitGate = Promise.withResolvers<{ source: PreparedSource; state: string }>() + const source = prepared(id) + const failure = new Error('commit failed') + const first = preparations.reserve(id, () => Promise.resolve(source), () => { + commitStarted.resolve(undefined) + return commitGate.promise + }) + await commitStarted.promise + expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/) + const second = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed) + + commitGate.reject(failure) + await expect(first).rejects.toBe(failure) + await expect(second).resolves.toBeUndefined() + expect(preparations.has(id)).toBe(false) + }) + + it('returns a post-commit cancellation to the ready pool', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('post-commit-cancel') + const source = prepared(id) + const controller = new AbortController() + const reason = new Error('cancel after commit') + + await expect(preparations.reserve(id, () => Promise.resolve(source), async (value) => { + controller.abort(reason) + return { source: value, state: value.label } + }, controller.signal)).rejects.toBe(reason) + + expect(preparations.takeReady(id)).toBe(source) + expect(preparations.takeReady(id)).toBeUndefined() + }) + + it('does not revive an invalidated commit after post-commit cancellation', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('invalidated-commit-cancel') + const source = prepared(id) + const commitStarted = Promise.withResolvers<undefined>() + const commitGate = Promise.withResolvers<undefined>() + const controller = new AbortController() + const reason = new Error('cancel invalidated commit') + const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => { + commitStarted.resolve(undefined) + await commitGate.promise + return { source: value, state: value.label } + }, controller.signal) + + await commitStarted.promise + preparations.invalidate(id) + controller.abort(reason) + commitGate.resolve(undefined) + await expect(reservation).rejects.toBe(reason) + expect(preparations.has(id)).toBe(false) + }) + + it('does not reserve an entry invalidated while its commit succeeds', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('invalidated-successful-commit') + const source = prepared(id) + const commitStarted = Promise.withResolvers<undefined>() + const commitGate = Promise.withResolvers<undefined>() + const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => { + commitStarted.resolve(undefined) + await commitGate.promise + return { source: value, state: value.label } + }) + + await commitStarted.promise + preparations.invalidate(id) + commitGate.resolve(undefined) + + await expect(reservation).resolves.toBeUndefined() + expect(preparations.has(id)).toBe(false) + }) + + it('returns undefined when a load is invalidated before reservation', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('invalidated-reservation') + const gate = Promise.withResolvers<PreparedSource>() + const reservation = preparations.reserve(id, () => gate.promise, committed) + preparations.invalidate(id) + gate.resolve(prepared(id)) + await expect(reservation).resolves.toBeUndefined() + }) + + it('skips pending adoption and accepts a ready source exactly once', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const id = SessionId('take-ready') + const gate = Promise.withResolvers<PreparedSource>() + const inspection = preparations.inspect(id, () => gate.promise) + expect(preparations.takeReady(id)).toBeUndefined() + const source = prepared(id) + gate.resolve(source) + await inspection + expect(preparations.takeReady(id)).toBe(source) + expect(preparations.takeReady(id)).toBeUndefined() + }) + + it('rejects publication while only an inspection exists', async () => { + const preparations = new SessionPreparations<PreparedSource, string>(1) + const source = prepared('inspection-publication') + await preparations.inspect(source.session.id, () => Promise.resolve(source)) + expect(() => preparations.reservationFor(source.session)).toThrow(/cannot publish/) + }) +}) + +describe('observeQueuedAbort', () => { + it('relays fulfillment and rejection exactly', async () => { + const signal = new AbortController().signal + await expect(observeQueuedAbort(Promise.resolve('value'), signal)).resolves.toBe('value') + const failure = { kind: 'failed' } + const rejected = Promise.withResolvers<never>() + rejected.reject(failure) + await expect(observeQueuedAbort(rejected.promise, signal)).rejects.toBe(failure) + }) + + it('rejects promptly with an exact abort reason and ignores later settlement', async () => { + const operation = Promise.withResolvers<string>() + const controller = new AbortController() + const reason = { kind: 'aborted' } + const observed = observeQueuedAbort(operation.promise, controller.signal) + controller.abort(reason) + await expect(observed).rejects.toBe(reason) + operation.resolve('late') + await Promise.resolve() + }) + + it('observes a pre-aborted signal through the default start predicate', async () => { + const controller = new AbortController() + controller.abort('pre-aborted') + await expect(observeQueuedAbort(new Promise<never>(() => {}), controller.signal)) + .rejects.toBe('pre-aborted') + }) + + it('lets an operation that already started own cancellation settlement', async () => { + const operation = Promise.withResolvers<string>() + const controller = new AbortController() + const observed = observeQueuedAbort(operation.promise, controller.signal, () => true) + controller.abort(new Error('too late')) + operation.resolve('owned') + await expect(observed).resolves.toBe('owned') + }) +}) diff --git a/packages/session-projection/README.i18n.yaml b/packages/session-projection/README.i18n.yaml index eb131c5d8b..5e03069b99 100644 --- a/packages/session-projection/README.i18n.yaml +++ b/packages/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/README.md -README.md: ae80a905705d205adb4a1ee66c72fa28d0d8b6d6 -README.zh.md: 97e25dd16caeeb444f5f5309eed3341d422fa1b1 +README.md: f1fc8337bbea6663915c19f39fdf02f14190370d +README.zh.md: 3323166f496fabafabcd96abeaec707a28ce552a diff --git a/packages/session-projection/README.md b/packages/session-projection/README.md index ae80a90570..f1fc8337bb 100644 --- a/packages/session-projection/README.md +++ b/packages/session-projection/README.md @@ -1,10 +1,10 @@ -# session-projection/ +# session-projection/ — session projection capability family English | [中文](README.zh.md) -Session-projection capability family: the seam through which domain host plugins serve whole current values of log-derived per-session state to client carriers. +This family serves current, log-derived per-session state to client carriers. -| Package | ctx key | Role | +| Package | Role | ctx key | |---|---|---| -| [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionDefinition` unit contract, and the eagerly driven registry carriers read synchronously | -| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | Persisted projection cache: durable per-session unit checkpoints over the domain data form, throttled write-behind with mandatory turn/end + detach points, and the cold-read ladder (cache row + persistence tail replay) | +| [`session-projection/`](session-projection/README.md) | Defines and drives session projection units | `ctx.sessionProjections` | +| [`session-projection-cache/`](session-projection-cache/README.md) | Persists and restores projection checkpoints | `ctx.sessionProjectionCache` | diff --git a/packages/session-projection/README.zh.md b/packages/session-projection/README.zh.md index 97e25dd16c..3323166f49 100644 --- a/packages/session-projection/README.zh.md +++ b/packages/session-projection/README.zh.md @@ -1,10 +1,10 @@ -# session-projection/ +# session-projection/:会话投影能力家族 [English](README.md) | 中文 -会话投影能力家族:领域 host 插件经由此 seam,把日志派生的按会话状态的当前全量值供给客户端载体。 +本家族向客户端载体提供从日志派生的当前逐会话状态。 -| 包 | ctx 键 | 职责 | +| 包 | 职责 | ctx 键 | |---|---|---| -| [`session-projection`](session-projection/README.md) | `sessionProjections` | 接口包(package):merge-extensible 的 `SessionProjectionMap` 类型表、`ProjectionDefinition` 单元契约,以及供载体同步读取的正向驱动注册表 | -| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | 持久投影缓存:基于域数据形态的按会话单元 checkpoint 持久化、带 turn/end + detach 两个必写点的节流后写,以及冷读阶梯(缓存行 + 持久化尾部重放) | +| [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` | +| [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` | diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index c1bdd6c5b8..37c8d986f9 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e -README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4 +README.zh.md: 827480658b8c69647380a782c1a983b390380108 diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index ab4076df28..827480658b 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 +持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: -- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。 - **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 -- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 -- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。 +- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 +- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。 ## 写策略 @@ -27,11 +27,11 @@ ## 列表读(`cachedSnapshot(meta)`) -零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值仓时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 +零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 ## 冷读(`coldSnapshot(id, signal?)`) -读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 +读取阶梯,正常路径无需加载全量日志:缓存行 → `sessionProjections.restoreFloor`(锚定在最低可用水位之前一个事件的位置)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 `write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。 @@ -49,9 +49,9 @@ ## 模型体验 -无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 +无,因为缓存只持久化并恢复 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 -#### KV 缓存影响 +#### KV Cache 影响 无;缓存从不组装或发送提供方请求。 diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json index d06a49def0..2be692665f 100644 --- a/packages/session-projection/session-projection-cache/package.json +++ b/packages/session-projection/session-projection-cache/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 8cf9345772..8474e21594 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -221,7 +221,7 @@ describe('SessionProjectionCache write policy', () => { describe('SessionProjectionCache cold read', () => { const storedLog = (marks: string[][]): SessionEvent[] => { const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, ] for (const m of marks) { events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } }) diff --git a/packages/session-projection/session-projection/README.i18n.yaml b/packages/session-projection/session-projection/README.i18n.yaml index a15bee5bac..34c1910b16 100644 --- a/packages/session-projection/session-projection/README.i18n.yaml +++ b/packages/session-projection/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection/README.md -README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e -README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025 +README.md: a42e88c262915fc5e53cd72205079cbc8029a8df +README.zh.md: f60a48bd41f0c33edb85a495ada9b6818ec46ee5 diff --git a/packages/session-projection/session-projection/README.md b/packages/session-projection/session-projection/README.md index f4898b8e56..a42e88c262 100644 --- a/packages/session-projection/session-projection/README.md +++ b/packages/session-projection/session-projection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRIVES every registered projection unit forward over committed session events and serves finished whole values to carriers (the api-proxy history tail page and `session/projection` push frame today; TUI/ACP/headless consumers later). A domain registers pure mathematics; the framework owns the drive. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). +Session-projection seam. It owns `ctx.sessionProjections`, the registry that drives every registered projection unit over committed session events and serves finished whole values to carriers, currently the api-proxy history tail page and `session/projection` push frame. A domain registers pure mathematics; the framework owns the drive. The [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) records the design rationale. ## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`) diff --git a/packages/session-projection/session-projection/README.zh.md b/packages/session-projection/session-projection/README.zh.md index 385862868d..f60a48bd41 100644 --- a/packages/session-projection/session-projection/README.zh.md +++ b/packages/session-projection/session-projection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话投影 seam。它拥有 `ctx.sessionProjections`——该注册表驱动每个已注册的投影单元在已提交会话事件上前进,并向载体供给成品全量值(今天是 api-proxy 历史尾页与 `session/projection` 推送帧;日后是 TUI、ACP(Agent Client Protocol)、headless 消费方)。领域注册的只是纯数学;驱动权归框架。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)。 +会话投影 seam。它拥有 `ctx.sessionProjections`:该注册表在已提交的会话事件上驱动每个已注册的投影单元,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)记录了设计理由。 ## 服务:`SessionProjectionRegistry`(ctx 键:`sessionProjections`) @@ -19,10 +19,10 @@ ## 契约 -- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都正向经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 +- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 -- **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 +- **单元的同步纪律。**`init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 - **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 @@ -39,9 +39,9 @@ 无;投影从不组装或发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 -- **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。 +- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/packages/session-projection/session-projection/package.json b/packages/session-projection/session-projection/package.json index 473b878f3f..37a081abfa 100644 --- a/packages/session-projection/session-projection/package.json +++ b/packages/session-projection/session-projection/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index dbf0c5ee6e..90c36ed579 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -5,8 +5,8 @@ * forward eagerly over committed session events. Domain host plugins * contribute pure mathematics (init/apply/view); the framework owns the * subscription, the per-session watermark cache, and change notification; - * carriers (api-proxy today, TUI/ACP/headless later) consume the snapshot - * read face and the change feed. Neither side knows the other + * carriers consume the snapshot read face and the change feed. Neither side + * knows the other * (capability-seam three-way split). Design authority: the session-projection * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 90b8de4a40..bd33914b2d 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -97,7 +97,7 @@ describe('SessionProjectionRegistry drive', () => { }) const event = mark(session, ['a']) // Non-matching event: apply returns the same reference — no notification. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }]) }) @@ -119,7 +119,7 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.onChanged((_session, key) => { changedKeys.push(key) }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) // count applied (+1 change), marks returned the same reference. expect(changedKeys).toEqual(['test/count']) const snapshot = ctx.sessionProjections.snapshot(session) @@ -235,7 +235,7 @@ describe('SessionProjectionRegistry drive', () => { }, tail, 3)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } }, { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } }, ...tail, @@ -261,7 +261,7 @@ describe('SessionProjectionRegistry drive', () => { 'test/count': { ver: 1, seq: 2, val: 3 }, } const tail: SessionEvent[] = [ - { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) @@ -309,7 +309,7 @@ describe('SessionProjectionRegistry drive', () => { expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) diff --git a/packages/session-query/README.i18n.yaml b/packages/session-query/README.i18n.yaml index f1f511b9c8..3911bf76a0 100644 --- a/packages/session-query/README.i18n.yaml +++ b/packages/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/README.md -README.md: 38edbf6b0303e3d5a7bd0dc1d180cd127c60f9dc -README.zh.md: f50b93076a55e7530420400089d48672d3ea6b40 +README.md: a9c9f33e2dc67e837c2be728d2b1a937ce50f13d +README.zh.md: a29dedba09271baa5836cb2d9d57c753ee41c660 diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 38edbf6b03..a9c9f33e2d 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -2,12 +2,10 @@ English | [中文](README.zh.md) -Trusted exact reads, relationship traces, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs. +This family provides authorized retrieval over live and durable session logs, independently of compaction. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` | -| [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` | -| [`tool-session-query/`](tool-session-query/README.md) | Workspace-authorized model-facing search, lineage, relationship, and exact event tools | — | - -The query service is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, one concrete backend owns the full-text lifecycle without a provider registry or coordinator, and the consumer leaves oversized plain-text results to the generic post-execute spill policy. +| [`session-query/`](session-query/README.md) | Defines trusted reads, relationship queries, and search operations | `ctx.sessionQuery` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | Implements session queries with SQLite full-text search | `ctx.sessionQuery` | +| [`tool-session-query/`](tool-session-query/README.md) | Exposes workspace-authorized session queries to the model | registers on `ctx.tools` | diff --git a/packages/session-query/README.zh.md b/packages/session-query/README.zh.md index f50b93076a..a29dedba09 100644 --- a/packages/session-query/README.zh.md +++ b/packages/session-query/README.zh.md @@ -2,12 +2,10 @@ [English](README.md) | 中文 -针对实时和持久会话日志提供可信的精确读取、关系跟踪、与提供方无关的语义过滤和 SQLite 全文搜索。 +本家族提供经过授权的实时与持久会话日志检索,且独立于压缩(compaction)。 -| 包(package) | 职责 | ctx 键 | +| 包 | 职责 | ctx 键 | |---|---|---| -| [`session-query/`](session-query/README.md) | 组合式服务契约:提供具体的逻辑语料库读取、跟踪和语义过滤,以及抽象全文方法 | `ctx.sessionQuery` | -| [`session-query-sqlite/`](session-query-sqlite/README.md) | 具体服务后端:使用 SQLite FTS5 持久基库和实时覆盖层 | `ctx.sessionQuery` | -| [`tool-session-query/`](tool-session-query/README.md) | 工作区授权的面向模型搜索、血缘、关系和精确事件工具 | 无 | - -查询服务与压缩(compaction)无关:它读取规范血缘、接口操作、已记录来源信息和语义事件文本,但不参与压缩策略或执行。一个抽象服务组合全部查询操作;一个具体后端负责全文生命周期,无需提供方注册表或协调器;消费方将过大的纯文本结果交给通用执行后 spill 策略。 +| [`session-query/`](session-query/README.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` | +| [`tool-session-query/`](tool-session-query/README.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` | diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index 651d00645d..9c2622086a 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1 -README.zh.md: 6eb1f3bca034a974128eb33381b5d3c383b55d12 +README.zh.md: aafd4d0a1873090c2101bafb634c3b0d8dcbc6eb diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index 6eb1f3bca0..aafd4d0a18 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -具体 `ctx.sessionQuery` 后端。`SessionQuerySqlite` 从接口包(package)继承精确读取、跟踪和提供方无关的过滤,并使用 SQLite FTS5 实现其两个全文方法。搜索使用实时优先的逻辑会话语料库,并按每个会话中匹配度最高的事件对跨会话结果分组。 +具体 `ctx.sessionQuery` 后端。`SessionQuerySqlite` 从接口包继承精确读取、跟踪和提供方无关的过滤,并使用 SQLite FTS5 实现其两个全文方法。搜索使用实时优先的逻辑会话语料库,并按每个会话中匹配度最高的事件对跨会话结果分组。 ## 搜索契约 diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 2d4758ba3e..4813b76b10 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index a79a309f19..36be5cb9e5 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -340,7 +340,7 @@ describe('SQLite session search', () => { { type: 'user/message', seq: 2, time: 12, data: createUserMessage({ content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' }, }), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } }, ] ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } }) ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } }) diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index fb266a82de..6d801d9d23 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md -README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063 -README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f +README.md: b75c1f23264cfa7c9323970b3c1a77ddcfe69be0 +README.zh.md: e1b9727ff892047c56d006e02906f3270fb11293 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index df97333be3..b75c1f2326 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -17,7 +17,7 @@ English | [中文](README.zh.md) - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable; a successfully read durable record that fails Session validation reports `SESSION_QUERY_CORRUPT_SESSION` instead. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 1a3df1ce38..e1b9727ff8 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -17,7 +17,7 @@ - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败;已经成功读取、但无法通过 Session 校验的持久化记录则以 `SESSION_QUERY_CORRUPT_SESSION` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 @@ -29,7 +29,7 @@ `SessionQueryService.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是由服务持有的带品牌 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下提供摘录。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到此次载荷观察。搜索请求只接受事件元数据过滤器,因为字面文本过滤使用上文所述扫描路径。 -该包(package)没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。 +该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。 `SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的表层、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。 diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index daf4d4e680..3a52808f02 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 475cc55dbf..a449799193 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -19,6 +19,7 @@ export interface Config { /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ export type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 649a80965a..8711597b69 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -2,7 +2,7 @@ import type { Context, Fiber } from 'cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' import { assertSessionHeadersCompatible } from './sources.ts' @@ -274,6 +274,13 @@ async function inspectPersisted( return await persistence.inspect(sessionId, signal) } catch (error: unknown) { if (signal?.aborted) signal.throwIfAborted() + if (error instanceof SessionPersistenceCorruptionError) { + throw new SessionQueryError( + `stored session "${sessionId}" is corrupt: ${errorMessage(error)}`, + 'SESSION_QUERY_CORRUPT_SESSION', + { cause: error }, + ) + } throw new SessionQueryError( `failed to inspect session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 805ca10395..d01ccb7aa3 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -15,7 +15,6 @@ export function extractSessionEventText(event: SessionEvent): string { case 'user/message': return contentText(event.data.content) case 'assistant/message': - case 'steering/message': return contentText(event.data.message.content) case 'tool/call': return joinText([event.data.name, event.data.arguments]) @@ -45,12 +44,9 @@ export function extractSessionEventText(event: SessionEvent): string { function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string { switch (reason.kind) { case 'error': - return 'failure' in reason - ? joinText(['error', reason.failure.message, reason.failure.code]) - : joinText(['error', reason.message, reason.code ?? '']) + return joinText(['error', reason.error.message]) case 'aborted': return 'aborted' - case 'disposed': case 'max-tokens': case 'interrupted': return reason.kind diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 809971b798..7878026345 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -143,7 +143,7 @@ export abstract class SessionQueryService extends Service { */ async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> { const loaded = await this._corpus.load(sessionId) - new Session(sessionId, loaded.events, loaded.header) + Session.create(sessionId, loaded.events, loaded.header) return { session: structuredClone(loaded.header), events: loaded.events.map(snapshotSessionEvent), diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index e40684c80b..1b139feacd 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -62,17 +62,10 @@ describe('session-query semantic extraction', () => { { type: 'user/message', seq: 2, time: 3, data: createUserMessage({ content: messageContent, source: { kind: 'plugin', plugin: 'test' }, }), surfaceOp: 'append' }, - { type: 'steering/message', seq: 3, time: 4, data: { - turn: 1, - message: createUserMessage({ - content: messageContent, - source: { kind: 'user' }, - }), - }, surfaceOp: 'append' }, - { type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, + { type: 'tool/call', seq: 3, time: 5, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, { type: 'tool/result', - seq: 5, + seq: 4, time: 6, data: { turn: 1, @@ -88,7 +81,7 @@ describe('session-query semantic extraction', () => { }, { type: 'tool/result', - seq: 6, + seq: 5, time: 7, data: { turn: 1, @@ -97,10 +90,10 @@ describe('session-query semantic extraction', () => { }, surfaceOp: 'append', }, - { type: 'todo/write', seq: 7, time: 8, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, + { type: 'todo/write', seq: 6, time: 8, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, ] - for (const event of events.slice(0, 4)) { + for (const event of events.slice(0, 3)) { expect(extractSessionEventText(event)).toBe('visible\nread\n{"path":"a"}\nnested') } expect(extractSessionEventText({ @@ -118,19 +111,18 @@ describe('session-query semantic extraction', () => { }, surfaceOp: 'append', })).toBe('') - expect(extractSessionEventText(events[4]!)).toBe('bash\n{"cmd":"pwd"}') - expect(extractSessionEventText(events[5]!)).toBe('failed\nOops\nE_OOPS') - expect(extractSessionEventText(events[6]!)).toBe('') - expect(extractSessionEventText(events[7]!)).toBe('in_progress\nship search') + expect(extractSessionEventText(events[3]!)).toBe('bash\n{"cmd":"pwd"}') + expect(extractSessionEventText(events[4]!)).toBe('failed\nOops\nE_OOPS') + expect(extractSessionEventText(events[5]!)).toBe('') + expect(extractSessionEventText(events[6]!)).toBe('in_progress\nship search') }) it('extracts meaningful turn outcomes and skips structural or unknown events', () => { const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [ - [{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'], - [{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'], - [{ kind: 'error', step: 2, failure: { message: 'provider boom', code: 'SERVER' } }, 'error\nprovider boom\nSERVER'], - [{ kind: 'aborted' }, 'aborted'], - [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } }, 'error\nboom'], + [{ kind: 'error', error: { message: 'provider boom', code: 'UNKNOWN' } }, 'error\nprovider boom'], + [{ kind: 'aborted', reason: { kind: 'user' } }, 'aborted'], + [{ kind: 'aborted', reason: { kind: 'disposed' } }, 'aborted'], [{ kind: 'max-tokens' }, 'max-tokens'], [{ kind: 'interrupted' }, 'interrupted'], [{ kind: 'completed' }, ''], @@ -140,7 +132,7 @@ describe('session-query semantic extraction', () => { expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text) } const structural: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, { type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } }, { type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 2713de9a72..ebcad51bd7 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -893,7 +893,7 @@ describe('session-query exact reads', () => { it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) const first = session.append( 'user/message', @@ -1008,7 +1008,7 @@ describe('session-query exact reads', () => { it('returns a bounded detached raw-event window and validates the request', async () => { const ctx = await liveContext({ readWindowMax: 1 }) const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) for (const text of ['one', 'two', 'three']) { session.append( 'user/message', @@ -1050,7 +1050,7 @@ describe('session-query exact reads', () => { ]) const ctx = await liveContext() const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ @@ -1090,7 +1090,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = await liveContext() const live = ctx.sessions.create(SessionId('live')) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 21e1a9089b..a6b5d73eb9 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -106,7 +106,7 @@ function expectCode(code: SessionQueryErrorCode): Error { } function appendTraceEvents(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/chunk', { turn: 1, @@ -348,7 +348,7 @@ describe('session event tracing', () => { expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ @@ -412,7 +412,7 @@ describe('session event tracing', () => { it.each([ ['non-surface sources', [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, sourceEventSeqs: [0] }, ]], ['invalid source array', [ { ...appendEvent(0), sourceEventSeqs: 'invalid' }, @@ -461,7 +461,7 @@ describe('session event tracing', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', }] as unknown as SessionEvent[] TracePersistence.reset([{ meta: durable, events }]) diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index e86449af9c..4bc40ec6a2 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md README.md: d973daf1124c4be05f7335b18661d431d45be39f -README.zh.md: b27d79a905a029d3750f24573e3c32785a314015 +README.zh.md: 18ff18520ba8bb9d3d3dba5091d6281a0a47b78a diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index b27d79a905..18ff18520b 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 +位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 ## 配置 diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 791a376cec..39b5ada943 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts index bf1dbd24f4..495897fddd 100644 --- a/packages/session-query/tool-session-query/src/service-boundary.ts +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -23,6 +23,10 @@ const SAFE_SESSION_QUERY_FAILURES = { code: 'SESSION_QUERY_ABORTED', message: 'session query was cancelled', }, + SESSION_QUERY_CORRUPT_SESSION: { + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: 'session event history is corrupt', + }, SESSION_QUERY_EVENT_NOT_FOUND: { code: 'SESSION_QUERY_EVENT_NOT_FOUND', message: 'session event was not found', diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 414bebc867..09c9c2e812 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -64,7 +64,7 @@ describe('tool-session-query with the real SQLite provider', () => { const caller = ctx.sessions.create(SessionId('caller'), { meta: { createdAt: 10, cwd: '/work' }, }) - caller.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + caller.append('turn/start', { turn: 1 }) caller.append( 'user/message', createUserMessage({ diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index df79984cef..db1cc4483b 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -64,7 +64,7 @@ function createSession( } function openStep(session: Session, text = 'prior needle'): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append( 'user/message', createUserMessage({ diff --git a/packages/session-title/README.i18n.yaml b/packages/session-title/README.i18n.yaml index 9cdb34dc49..2c5d1f2788 100644 --- a/packages/session-title/README.i18n.yaml +++ b/packages/session-title/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-title/README.md -README.md: 64bca8153e566e1590776af511662a873198106e -README.zh.md: 5ff52211ac975e31845285f99618503cd775eded +README.md: c2ccc1cd6d329a58f673a9ea5d47ce2f35c9dc41 +README.zh.md: b8df266a861da5cb20a76d0738bba4993218c3d5 diff --git a/packages/session-title/README.md b/packages/session-title/README.md index 64bca8153e..c2ccc1cd6d 100644 --- a/packages/session-title/README.md +++ b/packages/session-title/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call. +This family derives durable session titles from the session log, with an optional model-backed provider. | Package | Role | ctx key | |---|---|---| -| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` | -| [`session-title-llm/`](session-title-llm/README.md) | Shared route, request logging, prompt, timeout, stream, and validation helper | — | -| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` | -| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` | +| [`session-title/`](session-title/README.md) | Owns title state, fallback behavior, provider registration, and refresh | `ctx.sessionTitle` | +| [`session-title-llm/`](session-title-llm/README.md) | Provides shared model-backed title generation | — | +| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Titles a session from its first eligible human message | registers on `ctx.sessionTitle` | +| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Titles a session from all eligible human messages | registers on `ctx.sessionTitle` | -Only one provider may register at a time. The shared demo spine mounts the fallback service but leaves both model providers outside default composition, so deployments choose auxiliary cost and retitling cadence explicitly. +Deployments may register one model-backed provider; the service retains a deterministic fallback when none is present. diff --git a/packages/session-title/README.zh.md b/packages/session-title/README.zh.md index 5ff52211ac..b8df266a86 100644 --- a/packages/session-title/README.zh.md +++ b/packages/session-title/README.zh.md @@ -1,14 +1,14 @@ -# session-title/:日志支持的会话标题能力家族 +# session-title/:日志支持的会话标题能力族 [English](README.md) | 中文 -持久化的会话标题状态、一个可选异步提供方 seam,以及两个由模型支持、可选启用的实现。内置首消息回退属于服务本身,因此任何组合都能在不调用辅助模型的情况下为会话生成标题。 +该包族从会话日志派生持久会话标题,并支持可选的模型后端 provider。 -| 包 | 职责 | ctx 键 | +| 包 | 职责 | ctx key | |---|---|---| -| [`session-title/`](session-title/README.md) | 日志折叠、确定性回退、提供方注册表与刷新 API | `ctx.sessionTitle` | -| [`session-title-llm/`](session-title-llm/README.md) | 共享路由、请求日志记录、提示词、超时、流与验证辅助模块 | 无 | -| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | 使用第一条符合条件的用户消息的可选提供方 | 注册到 `ctx.sessionTitle` | -| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | 使用所有符合条件的用户消息的可选提供方 | 注册到 `ctx.sessionTitle` | +| [`session-title/`](session-title/README.md) | 负责标题状态、回退行为、provider 注册与刷新 | `ctx.sessionTitle` | +| [`session-title-llm/`](session-title-llm/README.md) | 提供共享的模型标题生成能力 | — | +| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | +| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | -同一时间只能注册一个提供方。共享 demo 主干会挂载回退服务,但默认组合不包含两个模型提供方,因此部署会显式选择辅助成本和重新生成标题的节奏。 +部署可注册一个模型后端 provider;未注册时,服务仍提供确定性回退。 diff --git a/packages/session-title/session-title-all-messages-llm/package.json b/packages/session-title/session-title-all-messages-llm/package.json index 06bdaaf8a0..546387c682 100644 --- a/packages/session-title/session-title-all-messages-llm/package.json +++ b/packages/session-title/session-title-all-messages-llm/package.json @@ -17,7 +17,7 @@ }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts index f8d6117d10..3c6ef6ed0b 100644 --- a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts @@ -31,8 +31,8 @@ async function settle(): Promise<void> { describe('all-messages LLM title provider', () => { it('includes seeded history and the latest prompt while inheriting the logged request route', async () => { - const seeded = new Session(SessionId('seed-source')) - seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const seeded = Session.create(SessionId('seed-source')) + seeded.append('turn/start', { turn: 1 }) const inherited = seeded.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -52,7 +52,7 @@ describe('all-messages LLM title provider', () => { seed: seeded.events, meta: { parentSession: seeded.id, seedLength: seeded.seq }, }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) const latest = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-title/session-title-first-message-llm/package.json b/packages/session-title/session-title-first-message-llm/package.json index f2cecb77a9..50b4a4d14c 100644 --- a/packages/session-title/session-title-first-message-llm/package.json +++ b/packages/session-title/session-title-first-message-llm/package.json @@ -17,7 +17,7 @@ }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts index 25fded0c38..eb0125efd9 100644 --- a/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts @@ -93,7 +93,6 @@ describe('session-title Loader composition', () => { const session = ctx.sessions.create(SessionId('loader-title')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Compose a title through Loader' }], diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts index 84b108a64f..6f39669cc8 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts @@ -37,7 +37,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider wit const session = ctx.sessions.create(SessionId('real-title-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }], diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts index 5b9f7240ec..79a80745e3 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts @@ -45,7 +45,7 @@ describe('first-message LLM title provider', () => { providerPlugin.apply(ctx, LLM_CONFIG) await expect(registered!.generate({ - session: new Session(SessionId('empty-first-provider')), + session: Session.create(SessionId('empty-first-provider')), messages: [], signal: new AbortController().signal, })).rejects.toThrow(/requires one human message/) @@ -60,7 +60,7 @@ describe('first-message LLM title provider', () => { ctx.llm.registerAdapter(['title-route'], adapter) await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('first-plugin')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const first = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-title/session-title-llm/README.i18n.yaml b/packages/session-title/session-title-llm/README.i18n.yaml index 2ae140e9a5..4ae7cd4aa7 100644 --- a/packages/session-title/session-title-llm/README.i18n.yaml +++ b/packages/session-title/session-title-llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-title/session-title-llm/README.md README.md: 342687b5aa0cd35a70abf8cc66b3fe80342bce0b -README.zh.md: d86366b589ba016793ca63daa7279504761744b3 +README.zh.md: f6f39e07d300dbe53faa51514c29699ca1d6a3d2 diff --git a/packages/session-title/session-title-llm/README.zh.md b/packages/session-title/session-title-llm/README.zh.md index d86366b589..f6f39e07d3 100644 --- a/packages/session-title/session-title-llm/README.zh.md +++ b/packages/session-title/session-title-llm/README.zh.md @@ -4,7 +4,7 @@ 由模型支持的会话标题提供方的共享实现策略。它解析辅助路由,将精确选中的用户消息封装为 JSON,记录可分发的确切请求,应用语言感知的标题指令,强制执行输入和输出预算,组合超时与调用方取消,组装流,并返回带有确切来源 seq 和模型来源信息的规范化文本。 -此包(package)是普通库,不是 Cordis 插件。提供方插件调用 `registerSessionTitleLlmProvider()`,传入各自节奏与消息选择器;该函数验证共享配置,并将每次修订委派给 `generateSessionTitleWithLlm()`,使各插件的注册、路由、提示词、取消与验证行为不会漂移。 +此包是普通库,不是 Cordis 插件。提供方插件调用 `registerSessionTitleLlmProvider()`,传入各自节奏与消息选择器;该函数验证共享配置,并将每次修订委派给 `generateSessionTitleWithLlm()`,使各插件的注册、路由、提示词、取消与验证行为不会漂移。 ## 路由与失败契约 diff --git a/packages/session-title/session-title-llm/package.json b/packages/session-title/session-title-llm/package.json index b74cb4440c..64e4519662 100644 --- a/packages/session-title/session-title-llm/package.json +++ b/packages/session-title/session-title-llm/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts index ba608314f2..6572eb89c5 100644 --- a/packages/session-title/session-title-llm/tests/llm.spec.ts +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -80,7 +80,6 @@ function request(ctx: Context, signal = new AbortController().signal): SessionTi const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`)) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first prompt' }], diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml index 5760b076a4..b13c6ce917 100644 --- a/packages/session-title/session-title/README.i18n.yaml +++ b/packages/session-title/session-title/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e -README.zh.md: 38fc9f82e3fcd94233cad31e291b8258c97d0975 +README.zh.md: 3960be40e74507279ed2f21927ee8ad4224fe138 diff --git a/packages/session-title/session-title/README.zh.md b/packages/session-title/session-title/README.zh.md index 38fc9f82e3..3960be40e7 100644 --- a/packages/session-title/session-title/README.zh.md +++ b/packages/session-title/session-title/README.zh.md @@ -43,7 +43,7 @@ Fork 出的会话会原样继承种子中的标题事件。首消息节奏不会 #### Token 影响 -回退与已接受的提供方修订不会向主 agent 请求增加 token。可选提供方的独立辅助请求由对应提供方包(package)的文档说明。 +回退与已接受的提供方修订不会向主 agent 请求增加 token。可选提供方的独立辅助请求由对应提供方包的文档说明。 #### KV Cache 影响 diff --git a/packages/session-title/session-title/package.json b/packages/session-title/session-title/package.json index 8d6126236d..d167b257b2 100644 --- a/packages/session-title/session-title/package.json +++ b/packages/session-title/session-title/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts index c4023ccce7..7d5428983f 100644 --- a/packages/session-title/session-title/tests/persistence.spec.ts +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -25,7 +25,6 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI const session = ctx.sessions.create(id) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Persist this session title' }], diff --git a/packages/session-title/session-title/tests/projection.spec.ts b/packages/session-title/session-title/tests/projection.spec.ts index dc2a135eea..986cafc620 100644 --- a/packages/session-title/session-title/tests/projection.spec.ts +++ b/packages/session-title/session-title/tests/projection.spec.ts @@ -47,7 +47,7 @@ describe('title projection unit', () => { const firstSeq = appendTitle(session, 'First title') const secondSeq = appendTitle(session, 'Second title') // Unrelated event: same-reference apply, no notification. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(changes).toEqual([ { key: 'title', value: 'First title', seq: firstSeq }, { key: 'title', value: 'Second title', seq: secondSeq }, diff --git a/packages/session-title/session-title/tests/provider.spec.ts b/packages/session-title/session-title/tests/provider.spec.ts index c47b6c32e8..5bfe30ac14 100644 --- a/packages/session-title/session-title/tests/provider.spec.ts +++ b/packages/session-title/session-title/tests/provider.spec.ts @@ -55,7 +55,6 @@ describe('SessionTitleService provider lifecycle', () => { const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt') await settle() @@ -77,7 +76,6 @@ describe('SessionTitleService provider lifecycle', () => { }) child.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const childMessage = appendHumanPrompt(child, 'Child follow-up prompt') await settle() @@ -98,7 +96,6 @@ describe('SessionTitleService provider lifecycle', () => { }) child.append('turn/start', { turn: 3, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const latestMessage = appendHumanPrompt(child, 'Retitle the fork now') await settle() @@ -136,7 +133,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('first-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'Explain asynchronous title generation') await settle() @@ -195,7 +191,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('dispose-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = appendHumanPrompt(session, 'Generate this title') await settle() @@ -245,7 +240,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('supersede')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'First prompt') await settle() @@ -286,7 +280,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('unchanged-route')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'First routed prompt') await settle() @@ -298,7 +291,6 @@ describe('SessionTitleService provider lifecycle', () => { session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const second = appendHumanPrompt(session, 'Second prompt on the same route') await settle() @@ -345,7 +337,6 @@ describe('SessionTitleService provider lifecycle', () => { const pending = ctx.sessions.create(SessionId('unmatched-boundary')) pending.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendHumanPrompt(pending, 'Wait for a matching request boundary') await settle() @@ -369,7 +360,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('failure')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendHumanPrompt(session, 'Keep a fallback') await settle() diff --git a/packages/session-title/session-title/tests/rename.spec.ts b/packages/session-title/session-title/tests/rename.spec.ts index 01d613ee3e..ff38900f47 100644 --- a/packages/session-title/session-title/tests/rename.spec.ts +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -34,7 +34,7 @@ describe('SessionTitleService.rename', () => { await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Original prompt text') await settle() @@ -61,7 +61,7 @@ describe('SessionTitleService.rename', () => { const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) - expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name')) + expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name')) .toThrow(/not live in this store/) }) @@ -79,7 +79,7 @@ describe('SessionTitleService.rename', () => { generate, }) const session = ctx.sessions.create(SessionId('rename-pin')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'First prompt') await settle() ctx.sessionTitle.rename(session, 'Pinned by hand') @@ -107,7 +107,7 @@ describe('SessionTitleService.rename', () => { await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Derivable prompt words') await settle() ctx.sessionTitle.rename(session, 'Pinned without provider') @@ -143,7 +143,7 @@ describe('SessionTitleService.rename', () => { generate, }) const session = ctx.sessions.create(SessionId('rename-supersede')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Prompt that triggers generation') session.append('request/header', { header: { config: { provider: 'main-route', model: 'chat-model' } }, @@ -169,7 +169,7 @@ describe('SessionTitleService.rename', () => { // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) const session = ctx.sessions.create(SessionId('rename-unpin-empty')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, '😀😀') await settle() ctx.sessionTitle.rename(session, 'Sticky emoji pin') diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 1b426ac94c..6a425ae36f 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -37,7 +37,6 @@ function startSession(ctx: Context, id: string): ReturnType<Context['sessions'][ const session = ctx.sessions.create(SessionId(id)) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) return session } @@ -82,7 +81,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined() expect(generate).not.toHaveBeenCalled() - await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached')))) + await expect(withProvider.sessionTitle.refresh(Session.create(SessionId('detached')))) .rejects.toThrow(/not live in this store/) const controller = new AbortController() controller.abort(new Error('already cancelled')) @@ -151,7 +150,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { disposeCtx.sessions.announce(disposed) disposed.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const disposedMessage = appendPrompt(disposed, 'Dispose this session') await settle() @@ -165,10 +163,9 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('shares one fallback across concurrent refreshes', async () => { const ctx = await setup() - const seed = new Session(SessionId('fallback-concurrency-seed')) + const seed = Session.create(SessionId('fallback-concurrency-seed')) seed.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -339,7 +336,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendPrompt(session, 'Detach before the fallback microtask') await settle() diff --git a/packages/session-title/session-title/tests/session-title.spec.ts b/packages/session-title/session-title/tests/session-title.spec.ts index b43d5a1876..c6c3c4b9d3 100644 --- a/packages/session-title/session-title/tests/session-title.spec.ts +++ b/packages/session-title/session-title/tests/session-title.spec.ts @@ -43,7 +43,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }], @@ -80,7 +79,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Explain this referenced session' }], @@ -99,7 +97,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'plugin text' }], @@ -134,7 +131,7 @@ describe('SessionTitleService', () => { }) it('folds the latest title event during replay', () => { - const seed = new Session(SessionId('source')) + const seed = Session.create(SessionId('source')) seed.append('session/title', { title: 'Earlier', messageSeqs: [1], diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml index 7c78505615..ec2d2124a3 100644 --- a/packages/settings/README.i18n.yaml +++ b/packages/settings/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/settings/README.md -README.md: 7a91355dd01805938944f0abce77765021288e6d -README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0 +README.md: 3f43647f0558abf53c373ef7d97af16c0e17d2fe +README.zh.md: b5779bfe5da4cae148bcc6515ef13790f78bca7a diff --git a/packages/settings/README.md b/packages/settings/README.md index 7a91355dd0..3f43647f05 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -2,11 +2,9 @@ English | [中文](README.zh.md) -The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages. +This family resolves user-editable configuration through registered namespaces and swappable storage providers. | Package | Role | ctx key | |---|---|---| -| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` | -| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) | - -The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document. +| [`settings/`](settings/README.md) | Defines namespace registration, layered resolution, and commits | `ctx.settings` | +| [`settings-local/`](settings-local/README.md) | Stores settings in a local file and observes external edits | registers on `ctx.settings` | diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md index 2df40b67eb..b5779bfe5d 100644 --- a/packages/settings/README.zh.md +++ b/packages/settings/README.zh.md @@ -1,12 +1,10 @@ -# settings/ — 用户设置能力族 +# settings/:用户设置能力族 [English](README.md) | 中文 -用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。 +该包族通过注册的命名空间与可替换存储 provider 解析用户可编辑配置。 -| 包 | 角色 | ctx key | +| 包 | 职责 | ctx key | |---|---|---| -| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` | -| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) | - -接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。 +| [`settings/`](settings/README.md) | 定义命名空间注册、分层解析与提交 | `ctx.settings` | +| [`settings-local/`](settings-local/README.md) | 在本地文件中存储设置并观察外部编辑 | 注册到 `ctx.settings` | diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 2ca145145a..ec26e4241f 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-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/settings/settings-local/README.md -README.md: 344300c33879918e836b6e208b172343cc472faa -README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f +README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4 +README.zh.md: 03c7ef7c35312d9e1e4a86b0df71412875af7d6c diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index 344300c338..d1f3d755f9 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -24,8 +24,9 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. -- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal. +- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. +- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target. ## Model Experience diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 7e4913c088..03c7ef7c35 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -24,18 +24,19 @@ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 -- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。 +- **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 +- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。 -## Model Experience +## 模型体验 间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。 -#### KV Cache effect +#### KV Cache 影响 无直接失效;请求前缀的变更由消费插件拥有。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 - **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 0040b65507..6a9185ec2c 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 6b14eccc8f..88a2619e13 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,7 +10,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' @@ -96,6 +96,11 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** Whether an exclusive file create found an existing document. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z<Config> = z.object({ @@ -139,6 +144,29 @@ export class SettingsLocal extends Settings { return true } + /** The resolved YAML/JSON document path exposed to local configuration surfaces. */ + override get documentPath(): string { + return this.spec.filename + } + + /** Materialize an absent owner-only document, then return its resolved path. */ + override prepareDocument(): Promise<string> { + return this.enqueue(async () => { + await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) + await withFileLock(this.spec.filename, async () => { + try { + await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 }) + } catch (error) { + if (isEEXIST(error)) return + throw error + } + this.text = '' + if (!this.isClosed()) this.publish({}) + }) + return this.spec.filename + }) + } + protected async load(): Promise<Record<string, unknown>> { let text: string try { @@ -206,34 +234,36 @@ export class SettingsLocal extends Settings { // failure: an existing-but-invalid document must fail loud, never be // silently ignored or overwritten. yield* super[Service.init]() - if (!this.spec.watch) return - const watcher = chokidarWatch(this.spec.filename, { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: this.spec.debounceMs, - pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), - }, - }) - watcher.on('all', () => { - if (this.closed) return - this.queueRefresh() - }) - watcher.on('ready', () => { - // The base init's load raced the watcher's own setup: a change written - // between that read and the watcher becoming active never fires an - // event. One reconcile at ready closes the gap. - if (this.closed) return - this.queueRefresh() - }) - watcher.on('error', (error) => { - this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) - this.ctx.logger.warn(error) - }) + const watcher = this.spec.watch + ? chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + : undefined + if (watcher !== undefined) { + watcher.on('all', () => { + if (this.closed) return + this.queueRefresh() + }) + watcher.on('ready', () => { + // The base init's load raced the watcher's own setup: a change written + // between that read and the watcher becoming active never fires an + // event. One reconcile at ready closes the gap. + if (this.closed) return + this.queueRefresh() + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + } yield async () => { - // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight operation so nothing publishes after disposal. + // Quiesce every operation chain, even when no watcher is configured. this.closed = true - await watcher.close() + await watcher?.close() await this.operations } } diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 0b753675f5..7df16b48d3 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -4,6 +4,7 @@ import z from 'schemastery' import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsLocal, resolveSpec } from '../src/index.ts' @@ -48,12 +49,37 @@ describe('resolveSpec', () => { describe('boot and reads', () => { it('resolves defaults over an absent file and reports writable', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false }) + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base: { fontSize: 16 }, }) expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) expect(ctx.settings.writable).toBe(true) + expect(ctx.settings.documentPath).toBe(path) + }) + + it('prepares an absent owner-only document without changing resolved settings', async () => { + const dir = await tempDir() + const path = join(dir, 'nested', 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await expect(ctx.settings.prepareDocument()).resolves.toBe(path) + expect(await readFile(path, 'utf8')).toBe('') + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('preparing an existing document preserves its contents', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const contents = 'ui-theme:\n theme: light\n' + await writeFile(path, contents) + const ctx = await boot({ path, watch: false }) + + await expect(ctx.settings.prepareDocument()).resolves.toBe(path) + expect(await readFile(path, 'utf8')).toBe(contents) }) it('reads sections from an existing yaml document', async () => { @@ -77,6 +103,7 @@ describe('boot and reads', () => { it('defaults the file location under the configured harness home', async () => { const dir = await tempDir() const ctx = await boot({ dshHome: dir, watch: false }) + expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml')) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await scope.update({ theme: 'light' }) const written = await readFile(join(dir, 'settings.yaml'), 'utf8') @@ -119,7 +146,7 @@ describe('boot and reads', () => { it('fails loud at boot on unparsable yaml', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') - await writeFile(path, 'ui-theme: [unclosed\n') + await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 }) await expect(boot({ path, watch: false })).rejects.toThrow() }) @@ -366,7 +393,7 @@ describe('watch', () => { await new Promise(resolve => setTimeout(resolve, 300)) expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) - await writeFile(path, 'ui-theme:\n theme: dark\n') + await writeFileAtomic(path, 'ui-theme:\n theme: dark\n', { mode: 0o600 }) await vi.waitFor(() => { expect(scope.get().theme).toBe('dark') }, { timeout: 5000 }) diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts index 364f6f1bac..3ef6f16fa4 100644 --- a/packages/settings/settings-local/tests/lock-race.spec.ts +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -11,6 +11,10 @@ import { SettingsLocal } from '../src/index.ts' const state = vi.hoisted(() => ({ failTempWrite: false, + failDocumentCreate: false, + holdDocumentCreate: false, + documentCreateStarted: undefined as (() => void) | undefined, + continueDocumentCreate: undefined as Promise<void> | undefined, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -18,6 +22,15 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, writeFile: (async (path: unknown, ...rest: never[]) => { + if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) { + state.holdDocumentCreate = false + state.documentCreateStarted!() + await state.continueDocumentCreate! + } + if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) { + state.failDocumentCreate = false + throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' }) + } if (state.failTempWrite && String(path).endsWith('.tmp')) { state.failTempWrite = false throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' }) @@ -33,6 +46,10 @@ const cleanups: Array<() => Promise<void>> = [] afterEach(async () => { state.failTempWrite = false + state.failDocumentCreate = false + state.holdDocumentCreate = false + state.documentCreateStarted = undefined + state.continueDocumentCreate = undefined while (cleanups.length > 0) await cleanups.pop()!() }) @@ -51,6 +68,49 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro } describe('writer-lock failure cleanup', () => { + it('skips publication when an in-flight document create completes during teardown', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, { path, watch: false }) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + const settings = ctx.settings + settings.register(settingsNamespace('alpha'), AlphaSchema) + const published: number[] = [] + ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) }) + let markStarted!: () => void + const started = new Promise<void>((resolve) => { markStarted = resolve }) + let releaseCreate!: () => void + state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve }) + state.documentCreateStarted = markStarted + state.holdDocumentCreate = true + + const preparing = settings.prepareDocument() + await started + let disposed = false + const disposing = fiber.dispose() + void disposing.then(() => { disposed = true }) + await vi.waitFor(() => { + expect((settings as unknown as { closed: boolean }).closed).toBe(true) + }) + expect(disposed).toBe(false) + releaseCreate() + await expect(preparing).resolves.toBe(path) + await disposing + expect(await readFile(path, 'utf8')).toBe('') + expect(published).toEqual([]) + }) + + it('surfaces an exclusive document-create failure and releases the lock', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + state.failDocumentCreate = true + await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/) + await expect(access(`${path}.lock`)).rejects.toThrow() + }) + it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 9f920486d6..b2549e6be1 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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/settings/settings/README.md -README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b -README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565 +README.md: 0841624e553364fa03f7a1f1209aa72ab13d97e4 +README.zh.md: e8fe4485c2cf6cb6b8710347ebb8dcd1e8424b38 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 1f1ce07722..0841624e55 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -6,6 +6,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document ## Service API +- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target. +- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first. - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. @@ -18,7 +20,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document ## Provider contract -Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. +Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionally override `documentPath` and `prepareDocument()` for one local user-editable file, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. ## Events diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 0d96a0deda..e8fe4485c2 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -6,6 +6,8 @@ ## 服务 API +- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。Host 配置适配器据此派生可用性,而浏览器协议只暴露一个布尔能力,绝不暴露文件系统目标。 +- `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。 - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 @@ -16,9 +18,9 @@ - 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。 - 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 -## Provider 契约 +## 提供方契约 -子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 +子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 ## 事件 @@ -26,15 +28,15 @@ `settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的收容方式与 `settings/updated` 相同。 -## Model Experience +## 模型体验 间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。 -#### KV Cache effect +#### KV Cache 影响 无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 - **`redactSecrets` 并非一条可被证明的协议边界**:walker 只跟随 `object`/`dict`/`array`,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 会被**原样**返回,且 `secrets` 列表为空;而 `schema.toJSON()` 会把 secret 字段的 `.default(...)` 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 `describeForWire()`——它拒绝自己无法证明安全的 schema,并对序列化信封与错误文本做净化——此项暂缓。 diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 7586b4bb24..09112147ec 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 6dbfd688e5..f51e08cb03 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -403,6 +403,27 @@ export abstract class Settings extends Service { /** Whether {@link update} may persist through this provider. */ abstract readonly writable: boolean + /** + * Absolute path of the provider's user-editable document, when its storage + * is one local file. Configuration surfaces use this only as availability + * metadata; the guarded open operation resolves the path again Host-side. + * Non-file providers leave it undefined and expose no open-document affordance. + * @returns the absolute local document path, or undefined for non-file storage. + */ + get documentPath(): string | undefined { + return undefined + } + + /** + * Prepare the provider's user-editable document for a native editor. File + * providers may materialize an absent document before returning its path; + * non-file providers return undefined. + * @returns the absolute local document path, or undefined for non-file storage. + */ + prepareDocument(): Promise<string | undefined> { + return Promise.resolve(this.documentPath) + } + /** * Read the provider's current raw document (namespace to raw section). * @returns the detached raw document. diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 12e0d77ffe..dd3d5e1bc3 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -58,6 +58,14 @@ async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) { return { ctx, provider, fiber } } +describe('provider metadata', () => { + it('does not advertise a local document unless the provider overrides it', async () => { + const { ctx } = await boot() + expect(ctx.settings.documentPath).toBeUndefined() + await expect(ctx.settings.prepareDocument()).resolves.toBeUndefined() + }) +}) + /** Record every settings/updated emission. */ function recordUpdates(ctx: Context) { const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = [] diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index c17e3e6452..2d424c61dd 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/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/skill/README.md -README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822 -README.zh.md: 5981173d05e74e0576e83f4f0ec42894050675cf +README.md: d10049ac3e741350fddb42f430b70f063da1d12d +README.zh.md: 67f2da6f75edecd180ff861122c6392684ed9bdb diff --git a/packages/skill/README.md b/packages/skill/README.md index 4fb41dda5d..d10049ac3e 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -1,13 +1,13 @@ -# skill/ - skill capability family +# skill/ — skill capability family English | [中文](README.zh.md) -The canonical three-package capability seam for reusable agent instructions: a provider registry, a local implementation, and the model-facing catalog/loader consumer. All are **product** packages. +This family discovers reusable agent instructions and exposes them to the model through a provider-neutral catalog and loader. | Package | Role | ctx key | |---|---|---| -| `skill/` | Provider registry, precedence resolution, complete/incomplete catalog snapshots, and full-definition lookup | `ctx.skills` | -| `skill-local/` | Project/custom/user filesystem provider with membership watching | (registers on `ctx.skills`) | -| `tool-skill/` | Initial and replacement catalogs plus the model-facing `skill` loader | (registers on `ctx.tools`) | +| [`skill/`](skill/README.md) | Defines skill provider registration and lookup | `ctx.skills` | +| [`skill-local/`](skill-local/README.md) | Discovers skills from local filesystems | registers on `ctx.skills` | +| [`tool-skill/`](tool-skill/README.md) | Publishes the skill catalog and model-facing loader | registers on `ctx.tools` | -The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md). +This capability remains outside the core control spine and can use local, embedded, or remote providers without changing the model-facing contract. diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index 5981173d05..67f2da6f75 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -1,13 +1,13 @@ -# skill/ - skill(技能)能力家族 +# skill/:skill(技能)能力家族 [English](README.md) | 中文 -可复用 agent(智能体)指令的规范能力 seam 由三个包(package)组成:提供方注册表、本地实现,以及面向模型的目录/loader 消费方。全部均为**产品**包。 +本家族发现可复用的 agent(智能体)指令,并通过与提供方无关的目录和 loader 将其公开给模型。 | 包 | 职责 | ctx 键 | |---|---|---| -| `skill/` | 提供方注册表、优先级解析、完整/不完整目录快照和完整定义查找 | `ctx.skills` | -| `skill-local/` | 带目录成员关系监视的项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | -| `tool-skill/` | 初始目录和替换目录,以及面向模型的 `skill` loader | (注册到 `ctx.tools`) | +| [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` | +| [`skill-local/`](skill-local/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` | +| [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` | -接口位于 `skill/skill/`。提供方同步注册,并通过 `ctx.skills` 执行异步发现;`tool-skill` 只消费该接口,因此嵌入式或远程提供方可替换或补充 `skill-local`,无需改变面向模型的契约。`agent-core` 默认加载该家族,但它仍然是核心控制主干之外的能力,与 [`bash/`](../bash/README.md)、[`fs/`](../fs/README.md)、[`web/`](../web/README.md) 和 [`subagent/`](../subagent/README.md) 并列。 +该能力位于核心控制主干之外,可以使用本地、嵌入式或远程提供方,而无需更改面向模型的契约。 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 269054f9f8..4dcb4dbc68 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657 -README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7 +README.zh.md: 9ee09938737237df9e97e517c1dd44b511b7f596 diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 73a66831ad..9ee0993873 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -4,7 +4,7 @@ `ctx.skills` 注册表的本地文件系统提供方。 -该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。 +该包实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久化会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。 ## 插件 @@ -38,9 +38,9 @@ | 400 | `user-dsh` | `<dshHome>/skills` | | 500 | `user-agents` | `<agentsHome>/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变的仓库插件。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 -当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 +当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;遇到格式错误或非文本条目时,提供方会发出警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 ## 目录变更检测 @@ -50,9 +50,9 @@ 如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 -## Skill 格式 +## skill 格式 -Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。 +skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。 这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。 @@ -64,7 +64,7 @@ Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 M #### KV Cache 影响 -watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。 +watcher 触发的失效可促使上述消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。 ## 已知限制与暂缓事项 diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 8331a94a97..25306774b9 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index dbb25eb911..03d1b13fe8 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill/README.md README.md: f538ae668ccff291be86348627d5547150f460df -README.zh.md: 8a44f684ea4d9519a0af7866d272a8e7834aeda6 +README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6 diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8a44f684ea..d61a242d01 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -4,7 +4,7 @@ 纯 agent skill(智能体技能)提供方注册表。 -该包(package)负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。 +该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。 ## 服务:`SkillService`(ctx 键:`skills`) diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 3148a3f577..73469b89a7 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 9d706de673..b57689d742 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce -README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 +README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 +README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index d8e00bc839..8e0bff5d1c 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -8,13 +8,13 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools ## Catalog lifecycle -At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `<system-reminder>` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. +At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the pre-step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it adds an initial durable user-role `<system-reminder>` to a downstream `enter` decision. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -The digest covers the exact rendered text between the `<available_skills>` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing cannot decide whether a republish is needed and consumers never re-parse the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. -`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. +`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. ## Tool: `skill` @@ -28,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface. -Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries. +Tool execution does not add a synthetic context message. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body. Only the catalog projection adds replacement summaries. ## Model Experience diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 6c0df1d6e3..c6b815bef5 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -8,13 +8,13 @@ ## 目录生命周期 -每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 +每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `<system-reminder>` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 -`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 +`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 ## 工具:`skill` @@ -28,7 +28,7 @@ 无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。 -工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。 +工具执行不会添加合成上下文消息。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需重复正文。只有目录投影会添加替换摘要。 ## 模型体验 @@ -128,7 +128,7 @@ Load referenced resources only as needed. #### KV Cache 影响 -仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 工具错误 @@ -142,7 +142,7 @@ Load referenced resources only as needed. #### KV Cache 影响 -仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 9d86da58dd..e3ff7520b5 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index efc352fd3f..634824d6c2 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,7 +7,7 @@ import { createHash } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -22,9 +22,37 @@ export const name = 'tool-skill' export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 -const CATALOG_ENTRIES_START = '<available_skills>\n' -const CATALOG_ENTRIES_END = '</available_skills>' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const +/** + * Durable provenance for one published session skill catalog. The catalog is a + * `catalog`-form context, so it records the entries it published beside the + * model-facing prose: a consumer presenting the list must not re-parse the + * `<available_skills>` block, whose framing exists for the model. + */ +export interface SkillCatalogSource { + readonly kind: 'skill-catalog' + readonly form: 'catalog' + /** Marks a replacement catalog rather than this session's first publication. */ + readonly update?: true + /** Exactly the entries this message published, in catalog order. */ + readonly entries: readonly { readonly name: string; readonly description: string }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'skill-catalog': SkillCatalogSource + } +} + +/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */ +function catalogSourceEntries( + skills: SkillSummary[], + descriptionMaxLength: number, +): SkillCatalogSource['entries'] { + return skills.map(skill => ({ + name: skill.name, + description: catalogDescription(skill.description, descriptionMaxLength), + })) +} /** Model-facing skill catalog configuration. */ export interface Config { @@ -134,22 +162,46 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. - ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => { + ctx.on('agent/pre-step', async ( + agent: Agent, + _messages, + { signal }, + next, + ): Promise<PreStepDecision> => { + const decision = await next() + if (decision.kind === 'reject') return decision + signal.throwIfAborted() const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool const snapshot = toolVisible ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) : { skills: [], complete: true } signal.throwIfAborted() - if (!snapshot.complete) return + if (!snapshot.complete) return decision const skills = snapshot.skills.filter(isModelInvocable) - const digest = catalogDigest(skills, catalogDescriptionMaxLength) + const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) + const digest = digestCatalogEntries(entries) const history = catalogHistory(agent) - if (history.visibleDigest === digest) return - if (!history.published && skills.length === 0) return + const existing = catalogMessage(decision.messages) + if (history.visibleDigest === digest) { + return existing === undefined + ? decision + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } + } + if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision + if (!history.published && skills.length === 0) { + return existing === undefined + ? decision + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } + } const catalog = history.published - ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) - : renderCatalogMessage(skills, catalogDescriptionMaxLength) - agent.inject(catalog) + ? renderCatalogUpdate(entries) + : renderCatalogMessage(entries) + return { + kind: 'enter', + messages: existing === undefined + ? [...decision.messages, catalog] + : decision.messages.map(message => message.id === existing.message.id ? catalog : message), + } }) } @@ -199,8 +251,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB } } -function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { - const entries = renderCatalogEntries(skills, descriptionMaxLength) +function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage { return createUserMessage({ content: [{ type: 'text', @@ -209,20 +260,23 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:', '', '<available_skills>', - ...entries, + ...renderCatalogEntries(entries), '</available_skills>', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", '</system-reminder>', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries, + }, }) } -function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { - const entries = renderCatalogEntries(skills, descriptionMaxLength) - const availability = skills.length === 0 +function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage { + const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', ] @@ -237,31 +291,70 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', '', '<available_skills>', - ...entries, + ...renderCatalogEntries(entries), '</available_skills>', '', ...availability, '</system-reminder>', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries, + }, }) } -function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { - return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +/** + * Model-facing catalog lines, projected from the same entries the source records. + * The pseudo-XML escaping belongs to this frame, not to the published fact, so it + * is applied here and never stored. Names are `isSkillName`-validated and carry + * no escapable character. + */ +function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] { + return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`) } -function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string { - return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n')) -} - -function digestCatalogEntries(entries: string): string { +/** + * Catalog identity over the durable entry list rather than the rendered prose. + * The entries are what changes; the surrounding `<system-reminder>` framing is + * written for the model and must not decide whether a republish is needed. + */ +function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { + // JSON per entry rather than a separator character: every separator is itself + // a legal description character, so only quoting makes the boundary exact. + const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n') return createHash('sha256') - .update(entries) + .update(canonical) .digest('hex') } +/** + * Entries of one durable catalog message, or undefined when the record is not a + * usable catalog. + * + * `agent.session.events` may be a resumed, forked, or externally written seed, + * and seed validation only guarantees a source object with a non-empty `kind`; + * no per-kind field is checked there. An unreadable record is therefore treated + * as "not this plugin's catalog" — the posture the replaced content digest had — + * rather than throwing inside the step listener, which would fail every + * subsequent turn of that session. + */ +function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined { + const entries = (source as { entries?: unknown }).entries + if (!Array.isArray(entries)) return undefined + const readable: { name: string; description: string }[] = [] + for (const entry of entries as readonly unknown[]) { + if (typeof entry !== 'object' || entry === null) return undefined + const { name, description } = entry as { name?: unknown; description?: unknown } + if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined + readable.push({ name, description }) + } + return readable +} + function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) const events = agent.session.events @@ -270,36 +363,31 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool // The loop bounds prove the read-only event view contains this index. // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! - if (event.type !== 'user/message' - || event.data.source.kind !== 'plugin' - || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue - const digest = catalogContentDigest(event.data.content) - if (digest === undefined) continue + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) continue + const digest = digestCatalogEntries(entries) published = true if (visible.has(event.seq)) return { visibleDigest: digest, published } } return { published } } -function catalogContentDigest(content: UserMessage['content']): string | undefined { - if (content.length !== 1 || content[0]?.type !== 'text') return undefined - const text = content[0].text - const start = text.indexOf(CATALOG_ENTRIES_START) - if (start === -1) return undefined - const entriesStart = start + CATALOG_ENTRIES_START.length - const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) - if (end === -1) return undefined - const renderedEntries = text.slice(entriesStart, end) - const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries - return digestCatalogEntries(entries) +function catalogMessage( + messages: readonly UserMessage[], +): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined { + for (const message of messages) { + if (message.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(message.source) + if (entries !== undefined) return { message, entries } + } + return undefined } +/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */ function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() - const truncated = normalized.length <= maxLength - ? normalized - : `${normalized.slice(0, maxLength - 3)}...` - return escapeText(truncated) + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...` } function assertPositiveInteger(name: string, value: number, minimum = 1): void { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index df8f4e6fed..5d14b7c523 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -5,10 +5,10 @@ import { tmpdir } from 'node:os' import { Context } from 'cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -38,23 +38,20 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte function agentForCwd(cwd: string): Agent { const id = SessionId(`tool-skill-${cwd}`) - const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd }) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) return { ctx: new Context(), id, options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', - acceptsNextStep: false, send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, - reserveTurnAdmission: () => undefined, + steer: () => {}, + inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } @@ -64,24 +61,21 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { id: SessionId(id), options: {}, session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'running', - acceptsNextStep: false, ctx: new Context(), send: () => {}, - updateInbox: () => 'not-found', followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, - reserveTurnAdmission: () => undefined, + steer: () => {}, + inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') }, cancel() {}, + runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } function openMessageTurn(session: Session, turn = 1): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -89,13 +83,45 @@ function openMessageTurn(session: Session, turn = 1): void { } async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> { - await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } +} + +async function proposeStep( + ctx: Context, + agent: Agent, + messages: UserMessage[], +): Promise<PreStepDecision> { + const signal = new AbortController().signal + return await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + messages, + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages }), + ) } function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] { return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') +} + +function readableCatalog(event: Extract<SessionEvent, { type: 'user/message' }>): boolean { + const entries = (event.data.source as { entries?: unknown }).entries + return Array.isArray(entries) + && entries.every(entry => typeof entry === 'object' && entry !== null + && typeof (entry as { name?: unknown }).name === 'string' + && typeof (entry as { description?: unknown }).description === 'string') } function catalogContent(entries: string[]): Message['content'] { @@ -110,7 +136,17 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro } async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> { - await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } @@ -205,8 +241,19 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/step', (agent) => { - agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } })) + ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + ...decision, + messages: [ + ...decision.messages, + createUserMessage({ + content: [{ type: 'text', text: 'later contribution' }], + source: { kind: 'plugin', plugin: 'later-contribution' }, + }), + ], + } }) const prefix = await composePrefix(ctx, '/workspace') @@ -215,7 +262,21 @@ describe('dsh-tool-skill', () => { { id: expect.any(String) as unknown, role: 'user', - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + content: [{ type: 'text', text: 'later contribution' }], + source: { kind: 'plugin', plugin: 'later-contribution' }, + }, + { + id: expect.any(String) as unknown, + role: 'user', + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [ + { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' }, + { name: 'model-only-skill', description: 'Model-only skill.' }, + { name: 'z-skill', description: 'Long description Long description Long descript...' }, + ], + }, content: [{ type: 'text', text: [ @@ -233,14 +294,8 @@ describe('dsh-tool-skill', () => { ].join('\n'), }], }, - { - id: expect.any(String) as unknown, - role: 'user', - content: [{ type: 'text', text: 'later contribution' }], - source: { kind: 'plugin', plugin: 'later-contribution' }, - }, ]) - const rendered = JSON.stringify(prefix[0]) + const rendered = JSON.stringify(prefix[1]) expect(rendered).not.toContain('whenToUse') expect(rendered).not.toContain('secret-source') expect(rendered).not.toContain('/secret/path') @@ -284,7 +339,7 @@ describe('dsh-tool-skill', () => { invalidate = control.invalidate return provider }) - const session = new Session(SessionId('incomplete-prefix')) + const session = Session.create(SessionId('incomplete-prefix')) const agent = sessionAgent(session) openMessageTurn(session) @@ -300,7 +355,7 @@ describe('dsh-tool-skill', () => { it('records an empty baseline across repeated step observations', async () => { const home = await tempDir('tool-empty-step') const ctx = await setup(home) - const session = new Session(SessionId('empty-step')) + const session = Session.create(SessionId('empty-step')) const agent = sessionAgent(session) openMessageTurn(session) @@ -310,6 +365,92 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toEqual([]) }) + it('deduplicates or replaces a catalog already proposed for the same step', async () => { + const home = await tempDir('tool-proposed-catalog') + const ctx = await setup(home) + const disposeFirst = ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = Session.create(SessionId('proposed-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + await fireStep(ctx, agent, 1, 1) + const initial = catalogMessages(session)[0]?.data + if (initial === undefined) throw new Error('expected initial catalog') + + const duplicate = await proposeStep(ctx, agent, [initial]) + expect(duplicate).toEqual({ kind: 'enter', messages: [] }) + + ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + const companion = createUserMessage({ + content: [{ type: 'text', text: 'keep this message' }], + source: { kind: 'user' }, + }) + const replaced = await proposeStep(ctx, agent, [companion, initial]) + expect(replaced.kind).toBe('enter') + if (replaced.kind === 'reject') throw new Error('expected catalog replacement') + expect(replaced.messages).toHaveLength(2) + expect(replaced.messages[0]).toBe(companion) + expect(replaced.messages[1]?.id).not.toBe(initial.id) + expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill') + + disposeFirst() + }) + + it('removes a stale proposed catalog before the first empty baseline', async () => { + const home = await tempDir('tool-proposed-empty-catalog') + const ctx = await setup(home) + const session = Session.create(SessionId('proposed-empty-catalog')) + const malformed = createUserMessage({ + content: [{ type: 'text', text: 'preserve unreadable claimed context' }], + source: { kind: 'skill-catalog', form: 'catalog' } as never, + }) + const stale = createUserMessage({ + content: catalogContent(['- `stale-skill`: Stale skill']), + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'stale-skill', description: 'Stale skill' }], + }, + }) + + const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale]) + + expect(decision).toEqual({ kind: 'enter', messages: [malformed] }) + }) + + it('keeps a proposed catalog that already matches the current snapshot', async () => { + const home = await tempDir('tool-matching-proposal') + const ctx = await setup(home) + ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = Session.create(SessionId('matching-proposal')) + const proposed = createUserMessage({ + content: catalogContent(['- `first-skill`: First skill']), + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'first-skill', description: 'First skill' }], + }, + }) + + const decision = await proposeStep(ctx, sessionAgent(session), [proposed]) + + expect(decision).toEqual({ kind: 'enter', messages: [proposed] }) + }) + it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => { const home = await tempDir('tool-dynamic-catalog') const ctx = await setup(home) @@ -319,7 +460,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'First body.', }) - const session = new Session(SessionId('dynamic-catalog')) + const session = Session.create(SessionId('dynamic-catalog')) const agent = sessionAgent(session) openMessageTurn(session) @@ -354,7 +495,12 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toHaveLength(3) }) - it('resumes from the latest valid visible catalog content', async () => { + it('resumes from the durable entries of the latest visible catalog', async () => { + // Catalog identity moved onto `source.entries` when the catalog became a + // `catalog`-form context: the model-facing prose no longer decides whether + // a republish is needed, so a seeded message is recognized by its source + // alone and malformed prose can no longer hide (or fake) a published + // catalog. A foreign-sourced message is not this plugin's catalog at all. const home = await tempDir('tool-catalog-resume') const ctx = await setup(home) ctx.skills.register({ @@ -363,34 +509,82 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'Resumed body.', }) - const session = new Session(SessionId('catalog-resume')) + const session = Session.create(SessionId('catalog-resume')) const agent = sessionAgent(session) openMessageTurn(session) session.append('user/message', createUserMessage({ - content: catalogContent(['- `old-skill`: Old skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + content: [{ type: 'text', text: 'prose a reader cannot rely on' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'old-skill', description: 'Old skill' }], + }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'missing catalog markers' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], + content: catalogContent(['- `resumed-skill`: Resumed skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) await fireStep(ctx, agent, 1, 1) - expect(catalogMessages(session)).toHaveLength(6) - expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') + // The seeded entries differ from the live snapshot, so one replacement + // lands; the foreign-sourced lookalike neither counts as published nor + // suppresses it. + expect(catalogMessages(session)).toHaveLength(2) + const latest = catalogMessages(session).at(-1) + expect(latest?.data.source).toMatchObject({ + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries: [{ name: 'resumed-skill', description: 'Resumed skill' }], + }) + expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill') + + // A second step over unchanged entries republishes nothing. + await fireStep(ctx, agent, 1, 2) + expect(catalogMessages(session)).toHaveLength(2) + }) + + it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { + // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // and seed validation only guarantees a source object with a non-empty + // `kind`. A catalog whose entries are missing or wrongly shaped must be + // skipped like any foreign record; throwing here would fail every later + // step of that session at the latest possible point. + const home = await tempDir('tool-catalog-malformed') + const ctx = await setup(home) + ctx.skills.register({ + name: 'live-skill', + description: 'Live skill', + source: 'runtime', + content: 'Live body.', + }) + const session = Session.create(SessionId('catalog-malformed')) + const agent = sessionAgent(session) + openMessageTurn(session) + for (const source of [ + { kind: 'skill-catalog', form: 'catalog' }, + { kind: 'skill-catalog', form: 'catalog', entries: null }, + { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' }, + { kind: 'skill-catalog', form: 'catalog', entries: [null] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] }, + ]) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unreadable catalog' }], + source: source as never, + }), { surfaceOp: 'append' }) + } + + await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined() + + // None of the six counted as published, so the live catalog lands as a + // first publication rather than a replacement. + const published = catalogMessages(session).filter(event => readableCatalog(event)) + expect(published).toHaveLength(1) + expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' }) + expect(published[0]?.data.source).not.toHaveProperty('update') + expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill') }) it('re-establishes the current catalog after compaction hides its durable message', async () => { @@ -402,7 +596,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'First body.', }) - const session = new Session(SessionId('catalog-compaction')) + const session = Session.create(SessionId('catalog-compaction')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') @@ -427,7 +621,7 @@ describe('dsh-tool-skill', () => { const root = join(home, '.dsh/skills') await writeSkill(root, 'body-skill', 'Stable description', 'First body.') const ctx = await setup(home) - const session = new Session(SessionId('body-refresh')) + const session = Session.create(SessionId('body-refresh')) const agent = sessionAgent(session) openMessageTurn(session) @@ -457,7 +651,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'Stable body.', }) - const session = new Session(SessionId('incomplete-catalog')) + const session = Session.create(SessionId('incomplete-catalog')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') @@ -481,7 +675,7 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-restricted-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) - const session = new Session(SessionId('restricted-catalog')) + const session = Session.create(SessionId('restricted-catalog')) const agent = sessionAgent(session) openMessageTurn(session) const { scope } = await mintAgentScope(ctx, agent) diff --git a/packages/spill/README.i18n.yaml b/packages/spill/README.i18n.yaml index 6268366048..95800ac03e 100644 --- a/packages/spill/README.i18n.yaml +++ b/packages/spill/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/spill/README.md -README.md: cb6f1e9af94ee5b0d0c06a8c4293e37e2d1eb856 -README.zh.md: bf51d0d1d88db38fa01122c3e4c78b4e1e608fee +README.md: b96375b1a332cd6e3e4da9c8b1c98011aa97c8bc +README.zh.md: c8495068edf1035937c99198dfdb5db65c9a9e59 diff --git a/packages/spill/README.md b/packages/spill/README.md index cb6f1e9af9..b96375b1a3 100644 --- a/packages/spill/README.md +++ b/packages/spill/README.md @@ -1,15 +1,13 @@ -# spill/ - spill storage capability family +# spill/ — tool-output spill capability family English | [中文](README.zh.md) -The tool-output spill capability seam: an abstract storage interface, a local filesystem implementation, and the tool-result policy that uses it. All **product** packages. +This family persists oversized tool output and replaces the inline result with a bounded preview and retrieval locator. | Package | Role | ctx key | |---|---|---| -| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text and return a locator + retrieval hint) | `ctx.spillStore` | -| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillStore`) | -| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill locator | (no service surface) | +| [`spill/`](spill/README.md) | Defines spill storage | `ctx.spillStore` | +| [`spill-local/`](spill-local/README.md) | Stores spilled text in session-scoped local files | registers on `ctx.spillStore` | +| [`spill-policy/`](spill-policy/README.md) | Applies the post-execution spill policy | listens on `ctx.tools` | -The interface lives at `spill/spill/`. The split mirrors bash/fs: the seam owns storage only, `spill-local` owns the filesystem mechanics, and `spill-policy` owns WHEN to spill and the model-facing notice. Preview mechanics stay in [`util/retention`](../util/README.md) — the policy composes the two without either owning the other's job. - -See the [tool output spill Agent Note](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why final-result spill is separate from tool-owned early spill (bash streams, subagent rollouts) and why creation belongs to the runtime spill seam rather than the model-facing `write` tool. +See the [tool-output spill decision](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the boundary between storage, retention, and tool-owned output handling. diff --git a/packages/spill/README.zh.md b/packages/spill/README.zh.md index bf51d0d1d8..c8495068ed 100644 --- a/packages/spill/README.zh.md +++ b/packages/spill/README.zh.md @@ -1,15 +1,13 @@ -# spill/ - spill 存储能力家族 +# spill/:工具输出 spill 能力家族 [English](README.md) | 中文 -工具输出 spill 的能力 seam:一个抽象存储接口、一个本地文件系统实现,以及一个使用该实现的工具结果策略。全部均为**产品**包(package)。 +本家族持久化过大的工具输出,并以有界预览和取回定位信息替换内联结果。 | 包 | 职责 | ctx 键 | |---|---|---| -| `spill/` | 抽象 spill 存储 seam(`saveText`:持久化过大的工具文本,返回定位信息与取回指引) | `ctx.spillStore` | -| `spill-local/` | 本地文件系统后端:名称可防止路径遍历的私有会话级文件 | (注册到 `ctx.spillStore`) | -| `spill-policy/` | `tools/post-execute` 策略:将过大的纯文本结果替换为预览和 spill 定位信息 | (无服务接口) | +| [`spill/`](spill/README.md) | 定义 spill 存储 | `ctx.spillStore` | +| [`spill-local/`](spill-local/README.md) | 在会话范围的本地文件中存储 spill 文本 | 注册到 `ctx.spillStore` | +| [`spill-policy/`](spill-policy/README.md) | 应用执行后 spill 策略 | 监听 `ctx.tools` | -接口位于 `spill/spill/`。这种拆分方式与 bash/fs 相同:seam 只负责存储,`spill-local` 负责文件系统机制,`spill-policy` 负责决定何时 spill 以及面向模型的通知。预览机制位于 [`util/retention`](../util/README.md);策略只组合两者,不会让任何一方承担对方的职责。 - -设计原理见[工具输出 spill Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么最终结果 spill 要与工具自行提前 spill(bash 流、subagent rollout)分离,以及为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 +参见[工具输出 spill 决策](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中记录了存储、保留和工具自有输出处理之间的边界。 diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index bc84d5a611..0a0ab3823d 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-local/README.md README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070 -README.zh.md: 0af8d81951cd3eaddc95724b73a84da271b35591 +README.zh.md: 907a2b5c98f3ee07e0b896b8827e6a00fa1827df diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index 0af8d81951..907a2b5c98 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -18,7 +18,7 @@ |---|---|---| | `root` | 私有 0700 临时目录 | spill 文件的根目录。设置后可将这些文件保存在已知位置。 | -`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 +`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 ## 模型体验 diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 25a20db1d5..f185ab2c11 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml index 8d4746fc4c..36efaa8c0f 100644 --- a/packages/spill/spill-policy/README.i18n.yaml +++ b/packages/spill/spill-policy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md README.md: 7638f62c0426964d83d7a635ebf73d0f117abd78 -README.zh.md: 522a0dafba621a808bc9e8c007b01274edf11dd3 +README.zh.md: f3bb66d7a65edb6f4d2f2a0e86d0d83bb1a262ab diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md index 522a0dafba..f3bb66d7a6 100644 --- a/packages/spill/spill-policy/README.zh.md +++ b/packages/spill/spill-policy/README.zh.md @@ -34,7 +34,7 @@ ## 范围 -该策略只能看到最终格式化的呈现结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-local.maxBodyChars`),spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍然是必需的,并且与该策略相互独立。`glob`/`grep` 负责对项级呈现结果执行 spill,因为渲染前仍然存在完整的已获取值;bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall(瀑布式事件)监听器,然后再委托,因此无论插件加载顺序如何,普通工具自身的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 +该策略只能看到最终格式化的呈现结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-local.maxBodyChars`),spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍然是必需的,并且与该策略相互独立。`glob`/`grep` 负责对项级呈现结果执行 spill,因为渲染前仍然存在完整的已获取值;bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall(瀑布式事件)监听器,然后再委托,因此无论插件加载顺序如何,普通工具自身的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 ## 模型体验 @@ -50,7 +50,7 @@ #### KV Cache 影响 -仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index e8e9fb4631..f238acd3d6 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/spill/spill/README.i18n.yaml b/packages/spill/spill/README.i18n.yaml index 6a78a428f2..7e067f4464 100644 --- a/packages/spill/spill/README.i18n.yaml +++ b/packages/spill/spill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill/README.md README.md: c3434f90a8f7ab30baa6b237beab6dd9763f9bff -README.zh.md: 80a4431e9e57827e906fdfa4992a5a9af5b92aa9 +README.zh.md: 627097b6c481b593523de7ed7951605397724a1c diff --git a/packages/spill/spill/README.zh.md b/packages/spill/spill/README.zh.md index 80a4431e9e..627097b6c4 100644 --- a/packages/spill/spill/README.zh.md +++ b/packages/spill/spill/README.zh.md @@ -4,7 +4,7 @@ **spill 存储 seam**:抽象的 `SpillStore` 服务(`ctx.spillStore`)定义 spill 后端做什么,即持久化某个工具过大的文本,并返回面向模型的定位信息与取回指引;它不规定如何实现。 -该包(package)是 spill 能力的三个组成部分之一。拆分后,各项关注点可独立演进和替换: +该包是 spill 能力的三个组成部分之一。拆分后,各项关注点可独立演进和替换: | 包 | 职责 | |---|---| @@ -26,7 +26,7 @@ `SaveTextSpill`(owner、source、suggestedName、content)是请求;`SpillRef`(locator、bytes、retrievalHint)是结果。`SpillLocator` 是[带品牌类型](../../util/brand)的值,并以不透明字符串的形式呈现给模型;对 `dsh-spill-local` 而言它是本地路径,但未来的后端可以返回 URI、键或命令 token,无需修改策略/工具消费方。`SpillOwner.sessionId` 是保存时存储命名空间:fork 后的会话会从种子日志继承现有定位信息,无需复制文件或更改其归属;fork 后新产生的 spill 使用子会话 id。`SpillSource`(toolName、callId、label)是供后端命名和检查使用的描述性来源信息,而非访问控制信息。完整契约见 `src/types.ts`。 -设计原理见[工具输出 spill Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 +设计原理见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 ## 模型体验 diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index c66306ff0a..6a87d575a3 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/README.i18n.yaml b/packages/storage/README.i18n.yaml index e4e025bc82..58fc19a4f8 100644 --- a/packages/storage/README.i18n.yaml +++ b/packages/storage/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/storage/README.md -README.md: c9eca7355fd63e1023e9723486c86673e4c3574b -README.zh.md: 0af55728a717b3725febca7ce812ba9d737d2a1a +README.md: a5f9d0204b93445699d28b1285a4a18f0408930e +README.zh.md: 0710dfb57f96011755b253ad95a0f9d2c0923a70 diff --git a/packages/storage/README.md b/packages/storage/README.md index c9eca7355f..a5f9d0204b 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -The storage family persists everything that is not a session event log: a hub where named backends and typed data forms meet. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +This family persists application data other than session event logs through named backends and typed data forms. | Package | Role | ctx key | |---|---|---| -| `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` | -| `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` | -| `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` | -| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | `ctx.storageDomain` + `ctx.storage.domain` | +| [`storage/`](storage/README.md) | Connects registered backends with typed data forms | `ctx.storage` | +| [`storage-json/`](storage-json/README.md) | Stores data in JSON files | registers backend `json` | +| [`storage-sqlite/`](storage-sqlite/README.md) | Stores data in SQLite | registers backend `sqlite` | +| [`storage-domain/`](storage-domain/README.md) | Provides validated domain-record storage | `ctx.storageDomain` | -Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Each backend plugin publishes an internal lifecycle service after registration; the domain plugin injects every configured backend key before exposing its own service, so config-tree row order carries no startup semantics. Consumers never touch backends directly — they inject `storageDomain` and open declared domains through it. +Consumers use a data form rather than accessing a backend directly. The [domain storage decision](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) records the family design. diff --git a/packages/storage/README.zh.md b/packages/storage/README.zh.md index 0af55728a7..0710dfb57f 100644 --- a/packages/storage/README.zh.md +++ b/packages/storage/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -存储家族持久化会话事件日志之外的一切数据:命名后端与类型化数据形式在一个中心相接。设计记录:[领域 KV 存储 Agent Note(agent 决策记录)](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +本家族通过具名后端和类型化数据形式,持久化会话事件日志以外的应用数据。 -| 包(package) | 职责 | ctx key | +| 包 | 职责 | ctx key | |---|---|---| -| `storage/` | 中心:命名后端注册表 + 可通过合并扩展的数据形式挂载、后端分面词汇、共享一致性测试套件 | `ctx.storage` | -| `storage-json/` | JSON 后端:每个单元一个人类可读文件,以原子方式重写整个文件 | 注册后端 `json` | -| `storage-sqlite/` | SQLite 后端:一个数据库承载所有已路由单元,每行一个文档 | 注册后端 `sqlite` | -| `domain/` | 领域数据形式:经 zod 验证的记录、逐领域写入链、`domain/changed` 事件、按配置路由后端 | `ctx.storageDomain` + `ctx.storage.domain` | +| [`storage/`](storage/README.md) | 将已注册后端与类型化数据形式连接起来 | `ctx.storage` | +| [`storage-json/`](storage-json/README.md) | 在 JSON 文件中存储数据 | 注册后端 `json` | +| [`storage-sqlite/`](storage-sqlite/README.md) | 在 SQLite 中存储数据 | 注册后端 `sqlite` | +| [`storage-domain/`](storage-domain/README.md) | 提供经过验证的领域记录存储 | `ctx.storageDomain` | -每个后端拥有一种介质,并公开数据形状**分面**(目前为 `kv`;为未来的会话后端迁移预留追加日志分面)。每个后端插件都会在注册后发布内部生命周期服务;领域插件在公开自身服务前注入每个已配置的后端 key,因此配置树中的条目顺序不影响启动顺序。消费方绝不直接接触后端,而是注入 `storageDomain` 并通过它打开已声明的领域。 +消费方使用数据形式,而不是直接访问后端。[领域存储决策](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)记录了该家族的设计。 diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index 4f7fe3b7c7..2b5cbc7a14 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/storage/storage-domain/README.md README.md: 41e777c3d9a870530593a414839f905514a27134 -README.zh.md: 97c55bb6d798366726f181608eccbbdf536e52f1 +README.zh.md: 2f6b4daa09769d5a03eb30964c12447951c432e3 diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md index 97c55bb6d7..2f6b4daa09 100644 --- a/packages/storage/storage-domain/README.zh.md +++ b/packages/storage/storage-domain/README.zh.md @@ -4,7 +4,7 @@ DeepSeek Harness 存储中心的领域数据形式:在所有已配置的后端注册后,公开可注入的 `ctx.storageDomain` 服务及对应的 `ctx.storage.domain` 投影。一个领域通过 `defineDomain`(zod 记录 schema、从 `z.infer` 派生的类型)声明一次,通过 `DomainFacility.open` 打开,并由具有最终决定权的内存状态提供服务:读取同步执行;写入在每个领域各自的一条链上串行化,先在已路由后端达到持久状态,再更新内存并发出 `domain/changed`。打开领域的消费方负责管理句柄的生命周期,并通过 `Domain.close()` 释放它(幂等;通常作为其自身的 `ctx.effect` 资源释放函数);插件卸载时,该设施会关闭仍处于打开状态的领域。 -设计原理、打开语义和存储/领域分层见 [Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +设计原理、打开语义和存储/领域分层见 [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 ## 配置 @@ -19,7 +19,7 @@ DeepSeek Harness 存储中心的领域数据形式:在所有已配置的后端 #### 模型看到的内容 -无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它在 `ctx.storageDomain` 后面存储非会话数据(工作区记录、未来的会话伴随数据),只发出进程内 `domain/changed` 事件。只有消费方包通过自身有文档说明的接口呈现该事件时,它才会到达模型。 +无。该包不注册工具、不注入提示词,也不追加会话事件;它在 `ctx.storageDomain` 后面存储非会话数据(工作区记录、未来的会话伴随数据),只发出进程内 `domain/changed` 事件。只有消费方包通过自身有文档说明的接口呈现该事件时,它才会到达模型。 #### Token 影响 diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 1dffd143a9..aaa277b244 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 25b54978b6..0ca8f2d918 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/storage/storage-json/README.md README.md: c3417846a70f8a227a19a5abf834156d530e3597 -README.zh.md: 6a6ca9477fc0628516c97ff4d123beeea370d9be +README.zh.md: e66c711dca838620eb9c90a3edacd2639970505e diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index 6a6ca9477f..e66c711dca 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[存储中心](../storage/README.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +[存储中心](../storage/README.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 ## 模型 diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index bcc20b6ba1..4dc9bec40b 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index ea968a8cf5..468e931933 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/storage/storage-sqlite/README.md README.md: efc1b3ca54181a43c067594cc339ccc3a8fea510 -README.zh.md: 0d680af2c2197e5e2d8773163a59927618b0e6cd +README.zh.md: a2dffe43b8364f203980ac7306c9f3dd64df68d4 diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 0d680af2c2..a2dffe43b8 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -[存储中心](../storage/README.md)的 SQLite 后端:注册为后端 `sqlite`,通过一个数据库文件提供 `kv` facet;该文件使用 `node:sqlite`(也可以是 `:memory:`)。设计与取舍见[领域 KV 存储 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +[存储中心](../storage/README.md)的 SQLite 后端:注册为后端 `sqlite`,通过一个数据库文件提供 `kv` facet;该文件使用 `node:sqlite`(也可以是 `:memory:`)。设计与取舍见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 ## 存储模型 每行一个文档:每个单元表都会成为一个物理 STRICT 表 `"u_<unit>_<table>" (key TEXT PRIMARY KEY, value TEXT)`,其中 `value` 是记录的 JSON 文本,因此一个 key 只更新一行(高频变更领域路由到这里而非 JSON 后端的原因)。单元标识位于两个元数据表中:`units` 在单元首次打开时标记其格式版本,描述符不同时以 `version-mismatch` 拒绝;`unit_globals` 保存每个单元的全局单例行。物理布局版本位于 `PRAGMA user_version`;其他任何标记值都会被拒绝(未发布格式,不迁移)。单元名和表名在进入 DDL 之前依据中心的 `UNIT_NAME_RE` 进行验证,因此不会把外部输入插值到 SQL 标识符中。 -每个写入原语都是一条预处理语句:SQLite 的逐语句原子性无需显式事务即可满足 KV 契约,写入顺序仍由调用方负责(领域层写入链)。缺失目录和数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`),与 session-persistence SQLite 后端一致;在计划的介质层提取完成前,该包(package)逐字复用了后者的打开顺序。 +每个写入原语都是一条预处理语句:SQLite 的逐语句原子性无需显式事务即可满足 KV 契约,写入顺序仍由调用方负责(领域层写入链)。缺失目录和数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`),与 session-persistence SQLite 后端一致;在计划的介质层提取完成前,该包逐字复用了后者的打开顺序。 ## 配置(schemastery) diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index dc792fe350..cebde3d279 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml index d84666fcb7..4378ae55ad 100644 --- a/packages/storage/storage/README.i18n.yaml +++ b/packages/storage/storage/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/storage/storage/README.md -README.md: 994285842925539e73f8f11780f19495f71d0280 -README.zh.md: de0f802c167146d53942ce6cb3768625602d9253 +README.md: bf827220afe0d8b1cbc53b3a12e6d0034004e1d2 +README.zh.md: 6c1a5b4c05f665cdedd16fa67b47c87a04737e7b diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index 9942858429..bf827220af 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -2,22 +2,13 @@ English | [中文](README.zh.md) -Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, and data forms own semantics. The [storage family overview](../README.md) maps those packages; the [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) records the design rationale. ## Shape - `ctx.storage.backend` — name → backend table. Multiple backends stay mounted side by side (`json`, `sqlite`); which backend serves a consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. `register()` returns the disposer; duplicate names and unknown lookups fail loud. - `ctx.storage.mount(form, facility)` / `ctx.storage.form(form)` — data-form mounting. `StorageForms` is merge-extensible; the domain layer merges `domain` and is reached as `ctx.storage.domain`. -- A backend owns one medium (file-tree root, database file) and exposes optional data-shape **facets** — `kv` today; an append-log facet is reserved for the future session-backend migration. `src/backend.ts` is the normative contract text; `tests/contract.ts` exports the shared conformance suite every backend runs. - -## Packages in this group - -| Package | Role | -| --- | --- | -| `dsh-storage` | The hub service + backend vocabulary + shared conformance suite | -| `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite | -| `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row | -| `dsh-storage-domain` | Domain data form (`ctx.storage.domain`): typed schemas, write chain, change events | +- A backend owns one medium and exposes the data-shape facets it supports. `kv` is the current facet; `src/backend.ts` owns its exact contract. ## Model Experience @@ -37,5 +28,5 @@ Independent of live requests: the hub never touches a request prefix, so it cann ## Known Limitations and Deferred Work -- **`kv` is the only data shape** — the append-log facet the future session-backend migration needs is reserved in the design note but not yet defined; backends currently have exactly one facet to implement. +- **`kv` is the only data shape** — backends currently have one facet to implement. - **Forms resolve lazily** — reading `ctx.storage.domain` before the domain plugin mounts throws `form-not-mounted`; assemblies order plugins accordingly (misconfiguration fails loud rather than silently deferring). diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md index de0f802c16..6c1a5b4c05 100644 --- a/packages/storage/storage/README.zh.md +++ b/packages/storage/storage/README.zh.md @@ -2,22 +2,13 @@ [English](README.md) | 中文 -非会话数据的存储中心(`ctx.storage`):命名后端注册表加已挂载的数据形式设施。中心自身不执行 IO:后端拥有介质,数据形式拥有语义。设计与取舍见[领域 KV 存储 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +非会话数据的存储中心(`ctx.storage`):具名后端注册表加已挂载的数据形式设施。中心自身不执行 IO:后端拥有介质,数据形式拥有语义。[存储家族概述](../README.md)列出了这些包;[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)记录了设计理由。 ## 结构 - `ctx.storage.backend`:名称 → 后端表。多个后端并排保持挂载(`json`、`sqlite`);为消费方提供服务的后端由该消费方自身的配置决定(领域层的路由表),绝非中心的全局选择。`register()` 返回资源释放函数;注册重复名称或查找未知名称时都会明确报错。 - `ctx.storage.mount(form, facility)`/`ctx.storage.form(form)`:数据形式挂载。`StorageForms` 可通过合并扩展;领域层合并 `domain`,并通过 `ctx.storage.domain` 访问。 -- 后端拥有一种介质(文件树根、数据库文件),并公开可选的数据形状**分面**:目前为 `kv`;为未来的会话后端迁移预留追加日志分面。`src/backend.ts` 是规范契约文本;`tests/contract.ts` 导出每个后端都会运行的共享一致性测试套件。 - -## 该分组中的包 - -| 包(package) | 职责 | -| --- | --- | -| `dsh-storage` | 中心服务 + 后端词汇 + 共享一致性测试套件 | -| `dsh-storage-json` | JSON 后端:每个单元一个人类可读文件,以原子方式重写整个文件 | -| `dsh-storage-sqlite` | SQLite 后端:一个数据库承载所有已路由单元,每行一个文档 | -| `dsh-storage-domain` | 领域数据形式(`ctx.storage.domain`):类型化 schema、写入链、变更事件 | +- 后端拥有一种介质,并公开其支持的数据形状**分面**。当前分面为 `kv`;其确切契约由 `src/backend.ts` 负责。 ## 模型体验 @@ -37,5 +28,5 @@ ## 已知限制与暂缓事项 -- **`kv` 是唯一的数据形状**:设计记录为未来的会话后端迁移预留了 append-log facet,但尚未定义;后端目前恰好只有一个 facet 需要实现。 +- **`kv` 是唯一的数据形状**:后端目前只有一个分面需要实现。 - **数据形式按需解析**:在领域插件挂载前读取 `ctx.storage.domain` 会抛出 `form-not-mounted`;组装会按相应顺序排列插件(错误配置会明确报错,而不是静默推迟处理)。 diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 600eb997ee..1c78c1d434 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 360db31ade..9cde7517ab 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/README.md -README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211 -README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326 +README.md: 6aeb7fb1eaa9341dd72df614ca11d114f321fb83 +README.zh.md: a78cb365a8e96ad44c0c930c072372f88930906c diff --git a/packages/subagent/README.md b/packages/subagent/README.md index f9b04b4aa8..6aeb7fb1ea 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -2,20 +2,18 @@ English | [中文](README.zh.md) -The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry. +This family lets an agent delegate work to child agents. Multiple named providers may coexist in one context. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and continuable-child orchestration | `ctx.subagents` | -| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | -| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | -| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | -| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | -| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | -| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | -| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | +| [`subagent/`](subagent/README.md) | Defines provider registration, delegation, and continuation | `ctx.subagents` | +| [`subagent-inprocess/`](subagent-inprocess/README.md) | Provides the shared in-process run driver | — | +| [`subagent-spawn/`](subagent-spawn/README.md) | Starts a fresh in-process child | registers on `ctx.subagents` | +| [`subagent-fork/`](subagent-fork/README.md) | Starts an in-process child from the parent's completed history | registers on `ctx.subagents` | +| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` | +| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` | +| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` | +| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | +| [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. - -The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 0afc01a00a..a78cb365a8 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -2,20 +2,18 @@ [English](README.md) | 中文 -subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 agent。与 [bash](../bash/README.md) 和 [llm](../llm/README.md) 能力家族一样,这也是一种能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)),但有一个关键差异:**多个提供方实现在同一上下文中共存,并按名称注册**,而不是采用 bash 的单实现形态。该注册表仿照大语言模型(LLM)适配器注册表。 +本家族允许一个 agent(智能体)将工作委派给子 agent。多个具名提供方可在同一上下文中共存。 -| 包(package) | 角色 | ctx 键 | +| 包 | 职责 | ctx 键 | |---|---|---| -| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可继续子 agent 编排 | `ctx.subagents` | -| `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | -| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | -| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | -| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | -| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | -| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | -| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | +| [`subagent/`](subagent/README.md) | 定义提供方注册、委派和继续执行 | `ctx.subagents` | +| [`subagent-inprocess/`](subagent-inprocess/README.md) | 提供共享的进程内运行驱动器 | 无 | +| [`subagent-spawn/`](subagent-spawn/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` | +| [`subagent-fork/`](subagent-fork/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` | +| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` | +| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` | +| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` | +| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | +| [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 - -设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +参见[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)决策。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index e24b5f2f07..2f5532e9f4 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-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/subagent/subagent-acp/README.md -README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0 -README.zh.md: 216d66ed5d259c1bf2ea2383356df97be3e5ced1 +README.md: 83a5f60414528bdb768ffccd29f3091793f44b6b +README.zh.md: 4ea8daef9341897463f3dbedca86fd2c83b45514 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index efcc77c442..83a5f60414 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -61,8 +61,6 @@ The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/READ The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). -Keyless tests drive a scripted ACP subprocess over real stdio, including a Loader-composed stdio app proving parent-session cwd inheritance end to end. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`. - ## Model Experience ### Child-agent request @@ -100,4 +98,3 @@ Append-only; newly visible content follows the reusable request prefix and does - **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. - **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP. - **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut. -- **No snapshot-tier replay coverage** (`TODO(acp-subagent-replay)`) — an ACP child is its own process with its own replay shape, deferred. diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 216d66ed5d..4ea8daef93 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -59,9 +59,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 -本包(package)没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 - -无密钥测试通过真实 stdio 驱动脚本化 ACP 子进程,其中包括一个由 Loader 组合的 stdio 应用,用于端到端证明父会话 cwd 继承。带密钥 e2e 会驱动仓库中的真实 ACP agent;没有 `DEEPSEEK_API_KEY` 时自行跳过。 +本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 ## 模型体验 @@ -91,13 +89,12 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 -- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md))。 +- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md))。 - **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,本包尚未设计。 - **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 - **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。 - **权限提示自动回答**(`permission: allow | reject`):当前版本不会把子 agent 的 `session/request_permission` 呈现给人。 -- **没有快照层回放覆盖率**(`TODO(acp-subagent-replay)`):ACP 子 agent 拥有独立进程和独立回放形态,该工作延期处理。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b06a5e50ea..382d2e219a 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index cd2be512c9..3598c77d31 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: a0c0811b585b69a634e49a2c838137655f316585 -README.zh.md: e068e27c7cb2cfd39bbf723fabbc29b17f6676f8 +README.md: c0e2f4e9ece366e28492e32d97afa533fd948141 +README.zh.md: 4cf6c58fff44b1c11cf0a6c321da2c02d52bb8c9 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index a0c0811b58..c0e2f4e9ec 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -10,13 +10,13 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. -The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn was cut short — a partial answer survives cancel and error paths. +The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit. ## Stop-reason mapping -The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. +The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and maps it into the seam vocabulary: `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or an activity with no turn — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. ## Capabilities and context @@ -59,8 +59,6 @@ The child environment is the [`dsh-subprocess`](../../subprocess/README.md) seam The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). -Keyless tests drive the SDK client package's scripted fake runtime over real stdio, including a Loader-composed e2e where the child is a real second harness runtime proving parent-session cwd inheritance end to end (`tests/loader-composition.e2e.ts`). - ## Model Experience ### Child-agent request diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index e068e27c7c..4cf6c58fff 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -10,13 +10,13 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 -返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方运行一个 SDK 轮次,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或轮次被截断时已累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 +返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。 ## 停止原因映射 -子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告轮次结果;提供方将其映射为 seam 词汇。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或根本未运行轮次,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 契约禁止 `result` 被拒绝。 +SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个持久 `turn/end`,并将其映射为 seam 词汇:`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或不含轮次的活动,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 契约禁止 `result` 被拒绝。 ## 能力与上下文 @@ -57,9 +57,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte 子进程环境以 [`dsh-subprocess`](../../subprocess/README.md) seam 的 `scrubbedParentEnv()` 为基础,先移除疑似凭据和名称为 `DSH_*` 的环境变量,再合并显式 `config.env` 值。子进程由 SDK 客户端 spawn,而不是经由 `ctx.subprocess` spawn(这是 subprocess README 中记录的 SDK 托管传输例外),因此本后端会自行执行环境清理。JSON-RPC 协议格式才是真正的序列化边界。 -本包(package)没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 - -免密钥测试通过真实 stdio 驱动 SDK 客户端包的脚本化伪运行时,还包括一个 Loader 组合 e2e:子进程是真实的第二个 harness 运行时,端到端证明父会话 cwd 继承(`tests/loader-composition.e2e.ts`)。 +本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 ## 模型体验 @@ -89,7 +87,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 52df9c033c..07217be7c1 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index ec88cc1fb1..b8a01ea383 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -70,8 +70,8 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000 /** * Map a child turn-end reason to a harness {@link SubagentStopReason}. - * @param reason - the `session.finished` reason, or `undefined` when the - * child settled without running a turn. + * @param reason - the owned child run's final durable turn reason, or + * `undefined` when it settled without running a turn. * @returns the harness equivalent; an absent or unknown reason maps to * `error`, so an unclean stop is never reported as `completed`. */ @@ -191,7 +191,10 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe cancelSettled.then(() => 'cancelled' as const), ]) if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' } - return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) } + const lastEnd = turn.events.findLast( + (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end', + ) + return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) } }, collectOutput, cancelled: () => flags.cancelled, diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 6c2e608065..5603e0e9a0 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -73,10 +73,10 @@ describe('sdkStopReason', () => { it('maps each child turn-end reason to the harness vocabulary', () => { expect(sdkStopReason({ kind: 'completed' })).toBe('completed') expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens') - expect(sdkStopReason({ kind: 'aborted' })).toBe('aborted') - expect(sdkStopReason({ kind: 'error', step: 0, message: 'x' })).toBe('error') + expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'user' } })).toBe('aborted') + expect(sdkStopReason({ kind: 'error', error: { message: 'x', code: 'UNKNOWN' } })).toBe('error') expect(sdkStopReason({ kind: 'interrupted' })).toBe('error') - expect(sdkStopReason({ kind: 'disposed' })).toBe('error') + expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted') }) it('treats an absent or unknown reason as an error', () => { @@ -165,6 +165,17 @@ describe('dsh-subagent-dsh-sdk provider', () => { await ctx.fiber.dispose() }) + it('keeps streamed text when a malformed final message prevents completion', async () => { + const ctx = await setup({ FAKE_MALFORMED_MESSAGE: '1', FAKE_TEXT: 'stream-only answer' }) + const run = await ctx.subagents.start('dsh-sdk', request()) + const result = await run.result + + expect(result.stopReason).toBe('error') + expect(text(result.output)).toBe('stream-only answer') + await run.dispose() + await ctx.fiber.dispose() + }) + it('reports a settled-without-turn child as an error', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' }) const run = await ctx.subagents.start('dsh-sdk', request()) @@ -218,16 +229,15 @@ describe('dsh-subagent-dsh-sdk provider', () => { } }) - it('keeps accumulated streamed text when the turn is cut short before a full message', async () => { - // The fake streams one text-delta chunk and then violates the protocol on - // the same pipe; frame order guarantees the chunk was dispatched before - // the failure settles, so the accumulated partial text (no complete - // assistant/message ever arrived) must survive into the error result. + it('does not attribute streamed text when prompt acceptance is malformed', async () => { + // The fake streams one text-delta chunk but never returns the MessageId + // needed to establish this run's durable inbox receipt. The text therefore + // lies outside an owned activity interval and cannot become its output. const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) const run = await ctx.subagents.start('dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') - expect(text(result.output)).toBe('streamed then cut short') + expect(result.output).toEqual([]) await run.dispose() await ctx.fiber.dispose() }) diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml index 317762e160..cdfa061695 100644 --- a/packages/subagent/subagent-fork/README.i18n.yaml +++ b/packages/subagent/subagent-fork/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork/README.md README.md: 55475aee7841e91960de79887dfe9bf37afdf9da -README.zh.md: 3eec8cb51a47243a1f06416a3f8f99ae8df8e734 +README.zh.md: ef9e4b256931450c0b8664dcc640e000f01c01da diff --git a/packages/subagent/subagent-fork/README.zh.md b/packages/subagent/subagent-fork/README.zh.md index 3eec8cb51a..ef9e4b2569 100644 --- a/packages/subagent/subagent-fork/README.zh.md +++ b/packages/subagent/subagent-fork/README.zh.md @@ -31,7 +31,7 @@ fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: #### 模型看到的内容 -子 agent 先接收父 agent 已配平的完整轮次表层前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但不影响独立的指导内容。父 agent 的工具视图与权限不会被继承。可选的结构化输出请求会添加仅属于子 agent 的契约。父 agent 当前进行中的轮次会被排除。 +子 agent 先接收由父 agent 已配平的已完成轮次构成的表层前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但不影响独立的指导内容。父 agent 的工具视图与权限不会被继承。可选的结构化输出请求会添加仅属于子 agent 的契约。父 agent 当前进行中的轮次会被排除。 #### Token 影响 @@ -53,7 +53,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index aa93b83e03..1a77983d56 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 5e8341bff7..d2e12854be 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md -README.md: 61e5c8381fcd8972815129a8a2171fcf7d864113 -README.zh.md: 1caf43427229afcd7083f929adbaa083a1d1ac2e +README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d +README.zh.md: 7536d265584f1f6c69027cda6bc4d81ad88b7c7c diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 61e5c8381f..67f0cf5dd1 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -2,28 +2,31 @@ English | [中文](README.zh.md) -This package is the shared run driver for the two in-process providers' one-shot delegations. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. Continuable children never come through this driver: the continuation manager in `@deepseek-ai/dsh-subagent` composes and drives them directly, so this driver owns exactly one turn with one result. +This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. ## Start contract -`startInProcessRun(request, options): Promise<SubagentRun>` fulfills as soon as the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, while turn or infrastructure failures after publication settle through the returned run without hiding the child id. +`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle. The driver follows this sequence: -1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it together with `origin: 'subagent'` in the child session header. Origin is a coarse product-navigation classifier; the later descriptor remains lifecycle and continuation authority. -2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request. -3. Publish the child, retain the returned `AgentHandle`, and return its holder-owned run. The run's `result` drives one task with `child.followup(prompt)` followed by `child.whenIdle()`. -4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output. +1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. +2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. +3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. +4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. +5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. +This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. + When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership -The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the published run immediately installs its own listener and checks the signal again, closing the handoff race. Once publication has occurred, an abort preserves the returned child id, prevents unsubmitted work, and resolves an incomplete result as `aborted`; an abort during the turn cancels the child. +The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. -After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and awaits both `result` and the returned `AgentHandle.dispose()`; the handle's memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. A result rejection remains on `result`; `dispose()` rejects only when handle disposal fails, after both operations settle. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. +After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. ## Spawn and fork inputs @@ -109,4 +112,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 1caf434272..7536d26558 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -2,31 +2,35 @@ [English](README.md) | 中文 -本包是两个进程内提供方一次性委派共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。可继续子 agent 绝不通过本驱动器:`@deepseek-ai/dsh-subagent` 中的继续执行管理器会直接组合并驱动它们,因此本驱动器只拥有一个轮次和一个结果。 +本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。 ## 启动契约 -`startInProcessRun(request, options): Promise<SubagentRun>` 会在子 agent 发布到 `ctx.agents` 后立即兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳;发布后的轮次或基础设施故障则通过返回的 run 结算,且不会隐藏 child id。 +`startInProcessRun(request, options): Promise<SubagentRun>` 只在子 agent 发布到 `ctx.agents` 后才兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳,因此调用方绝不会收到创建到一半的句柄。 驱动器按以下顺序运行: -1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并与 `origin: 'subagent'` 一同持久化到子 agent 会话 header。origin 是粗粒度产品导航分类器;后续描述符仍是生命周期与继续执行的权威依据。 -2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时,以及一次性的 `agent/step` contribution;该 contribution 会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。 -3. 发布子 agent,保留返回的 `AgentHandle`,并返回由持有方拥有的 run。该 run 的 `result` 会通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。 +1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 +2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 +5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 + 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 + ## 取消与所有权 -必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;已发布的 run 会立即安装自己的监听器并再次检查信号,从而消除交接竞态。一旦完成发布,中止会保留已返回的 child id、阻止尚未提交的工作,并以 `aborted` 兑现未完成的结果;轮次期间发生中止时,则会取消子 agent。 +必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 -兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并同时等待 `result` 和返回的 `AgentHandle.dispose()`;该句柄通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。`result` 的 rejection 仍归 `result` 通道;只有句柄释放失败时,`dispose()` 才会在两项操作都结算后拒绝。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 +兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -## Spawn 与 fork 输入 +## spawn 与 fork 输入 -`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 +`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供已配平的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 深度强制在 `startInProcessRun` 内部完成:它通过 `delegationDepthOf` 读取父 agent 深度(持久化的 `SessionHeader.delegationDepth` 具有权威性;运行时 `AgentOptions.subagentDepth` 可以加深但绝不能降低该值,因此恢复后的子 agent 会保留预算),缺失值按顶层深度零处理,拒绝格式错误的存储值,并报告尝试的子 agent 深度超过 `maxDepth`。超过安全整数范围、无法表示的深度会触发 `RangeError`。子 agent 深度写入子 agent header,因此会在持久化和恢复后保留。 @@ -72,7 +76,7 @@ When you have your final answer, you MUST report it by calling the `structured_o #### Token 影响 -固定指令和能力 token 仅由该子 agent 支付。结果文本进入子 agent 历史,而只有捕获的值会成为父 agent 结果。 +固定指令和能力产生的 token 开销仅由该子 agent 承担。结果文本进入子 agent 历史,而只有捕获的值会成为父 agent 结果。 #### KV Cache 影响 @@ -90,7 +94,7 @@ When you have your final answer, you MUST report it by calling the `structured_o #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 父 agent 结果(间接) @@ -104,8 +108,9 @@ When you have your final answer, you MUST report it by calling the `structured_o #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 +- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。 diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 893fd4e342..e18752db74 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 7c3daa0f5f..04ce4e23f9 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -55,7 +55,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { case 'aborted': return 'aborted' case 'error': - case 'disposed': case 'interrupted': default: return 'error' @@ -76,10 +75,13 @@ function prePublicationAbort(): Error { /** Append one one-shot descriptor inside the child's initial turn before its first request. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { let appended = false - childCtx.on('agent/step', (agent) => { - if (appended) return - appended = true - agent.session.append('subagent/descriptor', descriptor) + childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { + const decision = await next() + if (!appended && decision.kind === 'enter') { + appended = true + agent.session.append('subagent/descriptor', descriptor) + } + return decision }) } diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 4ae58920bb..d175322381 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -92,7 +92,7 @@ describe('startInProcessRun', () => { const run = await startInProcessRun(request(parent), {}) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(flushes).toBe(1) + expect(flushes).toBe(0) await run.dispose() }) @@ -130,7 +130,7 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { + it('reports the turn outcome when later metadata is appended during flush', async () => { const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) let injected = false ctx.on('session/flush', (session) => { @@ -138,25 +138,19 @@ describe('startInProcessRun', () => { const lastEnd = session.events.findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true - const turn = lastEnd.data.turn + 1 - session.append('turn/start', { - turn, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, - }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }), { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) }) const run = await startInProcessRun(request(parent), {}) const result = await run.result const child = ctx.agents.get(run.id)! - expect(injected).toBe(true) + expect(injected).toBe(false) expect(child.session.events.findLast(event => event.type === 'turn/end')) - .toMatchObject({ data: { reason: { kind: 'completed' } } }) + .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') await run.dispose() }) @@ -288,7 +282,7 @@ describe('startInProcessRun', () => { expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } }) await signalled.dispose() const disposed = await startInProcessRun(request(parent), {}) diff --git a/packages/subagent/subagent-spawn/README.i18n.yaml b/packages/subagent/subagent-spawn/README.i18n.yaml index 00eb8d457f..0ad4f7d7ee 100644 --- a/packages/subagent/subagent-spawn/README.i18n.yaml +++ b/packages/subagent/subagent-spawn/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn/README.md README.md: 811f19e6e68362bd14e75d0a9059ee61fda3f015 -README.zh.md: 2b189f77c4ff63ca026f472187a55c68def18ea1 +README.zh.md: 2736143214039daa3129fd114d4294dc5fcb7d5e diff --git a/packages/subagent/subagent-spawn/README.zh.md b/packages/subagent/subagent-spawn/README.zh.md index 2b189f77c4..2736143214 100644 --- a/packages/subagent/subagent-spawn/README.zh.md +++ b/packages/subagent/subagent-spawn/README.zh.md @@ -26,7 +26,7 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: #### 模型看到的内容 -全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的模型和工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授权。 +全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的模型和工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授予。 #### Token 影响 @@ -44,11 +44,11 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: #### Token 影响 -父 agent 输入增加一个依赖数据的结果,并保留到上下文压缩(compaction)为止。 +父 agent 输入会增加一个取决于数据的结果,并保留到压缩(compaction)为止。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 647e0b005c..243e7afb52 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index afa1d2a1d2..389ef5e2a7 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -29,6 +30,7 @@ export async function spawnHarness(workdir: string): Promise<Context> { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index acb102ea1b..0310c0b0b3 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -200,18 +200,6 @@ describe('dsh-subagent-spawn', () => { expect(published).toEqual([]) }) - it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => { - const { ctx, parent } = await setup([]) - const controller = new AbortController() - ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') }) - const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) - const result = await run.result - expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) - const child = ctx.agents.get(run.id)! - expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false) - await run.dispose() - }) - it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index ceac4245a5..c8aefc7533 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: e54f0b98ec3649cec428a47026e6657a9749608b -README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 +README.md: a21aa6ae2822d68d513fd9409d77b3f3bf74a7a3 +README.zh.md: 3caa612aefcdac4f1dcdbcf4a3c1b81adc52c3d3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e54f0b98ec..a21aa6ae28 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -4,21 +4,7 @@ English | [中文](README.zh.md) The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport. -## Package roles - -The family separates the stable interface from implementations and model-facing tools: - -| Package | Role | -|---|---| -| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. | -| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | -| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | -| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | -| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | -| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | -| `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | - -Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract. +The [subagent family overview](../README.md) maps implementations and model-facing consumers. This package owns the provider registry, shared request and result contracts, durable descriptors, and continuable-child orchestration. Multiple named providers may coexist behind that contract. ## Service API diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1624fa5985..3caa612aef 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -4,21 +4,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。 -## 包角色 - -该能力族把稳定接口与实现、面向模型的工具分开: - -| 包 | 角色 | -|---|---| -| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | -| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | -| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | -| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | -| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | -| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | - -多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。 +[subagent 家族概述](../README.md)列出了实现和面向模型的消费方。本包负责提供方注册表、共享请求和结果契约、持久描述符以及可继续子级编排。多个具名提供方可以在该契约背后共存。 ## 服务 API @@ -26,7 +12,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | 成员 | 含义 | |---|---| -| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | +| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | | `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | @@ -37,7 +23,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | | `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 | -`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 +`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 @@ -56,13 +42,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 持久化描述符 -该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 +该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩(compaction)保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 ## 委派深度 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 +`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP(Agent Client Protocol)不可以),不表示是否继承工具、服务或权限。 ## 一次性所有权与生命周期 @@ -74,9 +60,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 可继续子 agent 与 Activation -可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 Agent 循环负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装器。 +可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 agent loop(智能体循环)负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装层。 -管理器根据 Agent 停稳状态和所拥有子集推导三个内部驻留条件,而非维护第二个状态机:running(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、waiting(已停稳但仍拥有至少一个未 dispose 的子 agent)、settled(已停稳且所有拥有的子 agent 都已 dispose,因此管理器 dispose `AgentHandle` 并移除 Activation)。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,且不会对当前轮次进行 steering(中途引导)。路由只取决于驻留状态:running 入队、waiting 唤醒同一 Agent,无 Activation 时则冷恢复一个新的。 +管理器根据 Agent 停稳状态和所拥有的子 agent 集合推导三个内部驻留条件,而非维护第二个状态机:running(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、waiting(已停稳但仍拥有至少一个未 dispose 的子 agent)、settled(已停稳且所有拥有的子 agent 都已 dispose,因此管理器 dispose `AgentHandle` 并移除 Activation)。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,且不会对当前轮次进行 steering(中途引导)。路由只取决于驻留状态:running 入队、waiting 唤醒同一 Agent,无 Activation 时则冷恢复一个新的。 管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。 @@ -84,17 +70,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 生命周期事件 -服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才结算,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。 +服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不会发出这对生命周期事件中的任何一个。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才结算,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 -可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 -`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 +`registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 @@ -110,9 +96,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 +- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 - **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。 - **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 8cd78c5526..c00caf7fb9 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index f690cab6cf..4b038871a1 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -47,6 +47,8 @@ import type SubagentActivationSetupRegistry from './activation-setup-registry.ts /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { readonly kind: 'coordinator' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the agent whose tool call produced the follow-up. */ readonly senderSessionId: SessionId } @@ -54,6 +56,8 @@ export interface CoordinatorMessageSource { /** Durable attribution for a continuable child's explicit parent report. */ export interface SubagentReportMessageSource { readonly kind: 'subagent-report' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the reporting child. */ readonly senderSessionId: SessionId } @@ -481,6 +485,7 @@ export class SubagentContinuationManager { ], source: { kind: 'subagent-report' as const, + form: 'relay' as const, senderSessionId: activation.childId, }, }) @@ -688,7 +693,7 @@ export class SubagentContinuationManager { } /** - * Cold-resume a persisted child: load and authorize its Session, fold the + * Cold-resume a persisted child: inspect and authorize its Session, fold the * generic descriptor, create the Activation through `ctx.agents.resume()`, * and submit the waiting turn. This never dispatches through a subagent * provider — the persisted Session already holds the initial prefix and the @@ -701,13 +706,13 @@ export class SubagentContinuationManager { options: SubagentFollowupOptions, ): Promise<MessageId> { const persistence = this.requirePersistence() - let loaded: Awaited<ReturnType<typeof persistence.load>> + let loaded: Awaited<ReturnType<typeof persistence.inspect>> try { - loaded = await persistence.load(childId) + loaded = await persistence.inspect(childId, options.signal) } catch (error: unknown) { + options.signal.throwIfAborted() throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) } - // The persistence seam takes no signal; recheck before any child work. options.signal.throwIfAborted() this.assertAdmitting(parent) // Authorize the persisted header before folding: only the durable child's @@ -724,17 +729,24 @@ export class SubagentContinuationManager { 'NOT_RESUMABLE', ) } - const activation = await this.materialize({ - childId, - provider: descriptor.provider, - parent, - agentOptions: { - ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, - ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, - }, - composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, - signal: options.signal, - }) + let activation: Activation + try { + activation = await this.materialize({ + childId, + provider: descriptor.provider, + parent, + agentOptions: { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + }, + composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, + signal: options.signal, + }) + } catch (error: unknown) { + options.signal.throwIfAborted() + if (error instanceof SubagentError) throw error + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } return this.submitMaterialized(activation, content, options.source, parent, options.signal) } @@ -847,16 +859,13 @@ export class SubagentContinuationManager { // quiet Agent from one whose accepted turn has not been admitted yet. // Registered through the child's own scoped context, so scope filtering // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { - /* v8 ignore next -- a dequeue of an id this manager never admitted needs + handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { + /* v8 ignore next -- a claim of an id this manager never admitted needs * another sender on the same child, which no current path allows. */ - if (activation.accepted.delete(item.message.id)) this.wake(activation) + if (activation.accepted.delete(message.id)) this.wake(activation) }) - handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { - // Deleting every id in the batch is unconditional; waking once afterwards - // costs nothing and avoids branching on which ids this manager admitted. - for (const item of items) activation.accepted.delete(item.message.id) - this.wake(activation) + handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { + if (activation.accepted.delete(message.id)) this.wake(activation) }) // Agent creation committed setup at its publication boundary; // revocations from here on are immediate live revocation. diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index 836b40009d..a6b5dcf3e1 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -25,7 +25,7 @@ export function seedDescriptorTurn( seed: readonly SessionEvent[] | undefined, descriptor: SubagentDescriptorData, ): SessionEvent[] { - const staged = new Session(childId, seed) + const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) return [...staged.events] } diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index ff1bd9fdfb..4e86664340 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -207,7 +207,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR return 'max-tokens' case 'aborted': case 'interrupted': - case 'disposed': return 'aborted' case 'error': return 'error' diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index c7d861c82f..cabbec121d 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,8 +1,9 @@ /** * Read-only interpretation of session-query lineage as durable subagent - * children. The module owns no catalog state and does not consult Activation, - * Agent-registry, continuation-manager, or provider state. A child's - * descriptor distinguishes one-shot work from a continuable conversation. + * children. Only descendants with durable `origin: 'subagent'` enter per-child + * inspection. The module owns no catalog state and does not consult Activation, + * Agent-registry, continuation-manager, or provider state. A child's descriptor + * distinguishes one-shot work from a continuable conversation. * * @module @deepseek-ai/dsh-subagent */ @@ -20,12 +21,13 @@ type SessionQueryRuntime = Pick< > /** - * One entry of a {@link listChildren} result in trace candidate order. A valid - * descriptor produces a `child`, a per-child inspection failure produces a - * `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows - * include a one-level, origin-classified descendant hint. Diagnostics are - * transient query results, never session events or catalog state, and never - * expose model-hidden descriptor content. + * One entry of a {@link listChildren} result in trace candidate order. Only a + * candidate whose durable header has `origin: 'subagent'` is inspected. A + * valid descriptor produces a `child`, a per-child inspection failure produces + * a `diagnostic`, and a candidate without its own descriptor is omitted. + * Healthy rows include a one-level, origin-classified descendant hint. + * Diagnostics are transient query results, never session events or catalog + * state, and never expose model-hidden descriptor content. */ export type SubagentListEntry = | { @@ -69,8 +71,9 @@ export type SubagentListEntry = } /** - * Interpret one parent's direct session descendants as session-backed subagents - * without loading or resuming an Agent. + * Interpret one parent's origin-classified direct descendants as session-backed + * subagents without loading or resuming an Agent. Ordinary forks are skipped + * before per-child event inspection. * @see {@link SubagentService.listChildren} for the public cancellation and * failure contract. * @param ctx - context carrying the optional session-query service. @@ -103,6 +106,7 @@ export async function listChildren( ) const entries: SubagentListEntry[] = [] for (const node of trace.descendants) { + if (node.session.header.origin !== 'subagent') continue const hasChildren = node.descendants.some( descendant => descendant.session.header.origin === 'subagent', ) @@ -218,6 +222,8 @@ function perChildDiagnosticReason( ): 'corrupt' | 'unavailable' | undefined { if (!(error instanceof SessionQueryError)) return undefined switch (error.code) { + case 'SESSION_QUERY_CORRUPT_SESSION': + return 'corrupt' case 'SESSION_QUERY_SESSION_NOT_FOUND': case 'SESSION_QUERY_EVENT_NOT_FOUND': case 'SESSION_QUERY_PERSISTENCE_FAILED': diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index abf71bb1aa..7b7a2ab541 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -142,11 +142,24 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> }, { timeout: 5_000 }) } +/** Observe calls at the Agent cancellation boundary without a production event. */ +function observeCancel(agent: Agent, callback: () => void): void { + const cancel = agent.cancel.bind(agent) + let observed = false + vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => { + if (!observed) { + observed = true + callback() + } + cancel(cause, options) + }) +} + describe('SubagentService.startContinuable', () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const enqueued: { id: MessageId; loggedYet: boolean }[] = [] - ctx.on('agent/inbox/enqueue', (agent, accepted) => { + ctx.on('agent/inbox/inserted', (agent, accepted) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) @@ -555,6 +568,47 @@ describe('SubagentService.followup residency routing', () => { .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) + it('propagates cancellation while inspecting a cold child', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const inspectStarted = Promise.withResolvers<undefined>() + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockImplementation((_id, signal) => { + return new Promise<never>((_resolve, reject) => { + if (signal === undefined) { + reject(new Error('cold inspection must receive the followup signal')) + return + } + inspectStarted.resolve(undefined) + signal.addEventListener('abort', () => { + reject(reason) + }, { once: true }) + }) + }) + const controller = new AbortController() + const reason = new Error('cold inspection cancelled') + + try { + const delivery = followup(ctx, parent, started.childId, message('cancel me'), controller.signal) + await inspectStarted.promise + controller.abort(reason) + await expect(delivery).rejects.toBe(reason) + } finally { + inspect.mockRestore() + } + }) + + it('preserves a SubagentError raised while cold-materializing a child', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const failure = new SubagentError('materialization denied', 'UNAUTHORIZED') + ctx.agents.resume = () => Promise.reject(failure) + + await expect(followup(ctx, parent, started.childId, message('continue'))) + .rejects.toBe(failure) + }) + it('cold-resumes a delivery that lost the race with final disposal', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -737,7 +791,9 @@ describe('continuable durability and teardown', () => { const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) const cancellations: SessionId[] = [] - ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + observeCancel(targetChild, () => { cancellations.push(targetChild.id) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) }) const drained = ctx.subagents.drainContinuableDescendants([parent]) const convergedDrain = ctx.subagents.drainContinuableDescendants([parent]) @@ -784,7 +840,8 @@ describe('continuable durability and teardown', () => { const grandchild = await ctx.subagents.startContinuable(startSpec(child)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const cancellations: SessionId[] = [] - ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) }) const drained = ctx.subagents.drainContinuableDescendants([child]) @@ -828,7 +885,8 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.get(intermediateId)).toBeUndefined() expect(ctx.agents.get(descendant.childId)).toBeDefined() const cancellations: SessionId[] = [] - ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + const descendantAgent = ctx.agents.get(descendant.childId)! + observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) }) const drained = ctx.subagents.drainContinuableDescendants([parent]) @@ -926,7 +984,7 @@ describe('continuable durability and teardown', () => { const drains: Promise<void>[] = [] const accepted: MessageId[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) }) + ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -967,12 +1025,12 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] - child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { order.push('enqueue') } }) - child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + observeCancel(child, () => { order.push('cancel') }) const delivery = followup(ctx, parent, started.childId, message('before drain')) // Let the child-lock operation reach the live admission cutoff. Admission @@ -1150,9 +1208,9 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { + ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { if (subject === parent) return next() - return { kind: 'block', reason: 'blocked by policy' } + return { kind: 'reject' } }) await followup(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) @@ -1258,7 +1316,7 @@ describe('continuable review regressions', () => { expect(found).toBeDefined() return found! }) - child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + observeCancel(child, () => { order.push('cancel') }) const drained = drainManager(ctx) hold.resolve(undefined) @@ -1298,7 +1356,7 @@ describe('continuable review regressions', () => { // Cancel from the synchronous enqueue observer: the discard fires after the // id is recorded but before `followup()` returns. - const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } @@ -1330,7 +1388,7 @@ describe('continuable review regressions', () => { await followup(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) - const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } @@ -1348,9 +1406,9 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { + ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { if (subject === parent) return next() - return { kind: 'block', reason: 'blocked by policy' } + return { kind: 'reject' } }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -1370,7 +1428,7 @@ describe('continuable review regressions', () => { const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. - ctx.on('agent/inbox/enqueue', (agent) => { + ctx.on('agent/inbox/inserted', (agent) => { if (agent.session.header.parentSession !== undefined) { registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 232bb3c23c..f8ceab50a8 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -113,10 +113,11 @@ describe('SubagentService.listChildren', () => { const parentId = SessionId('query-only-parent') ctx.sessions.create(parentId) const childId = SessionId('query-only-child') - const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } }) + const child = ctx.sessions.create(childId, { + meta: { parentSession: parentId, origin: 'subagent' }, + }) child.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) child.append('subagent/descriptor', descriptorPayload('query-only child')) @@ -193,6 +194,7 @@ describe('SubagentService.listChildren', () => { ] as SessionEvent[]) const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', { parentSession: coldParent, + origin: 'subagent', }, childEvents(descriptorPayload('persisted parent case'))) const entries = await ctx.subagents.listChildren(coldParent) expect(entries).toEqual([ @@ -203,28 +205,33 @@ describe('SubagentService.listChildren', () => { ]) }) - it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => { + it('orders children by createdAt then id without inspecting ordinary forks', async () => { const { ctx, parent } = await setup([]) // Authored headers pin the ordering key deterministically: same createdAt // ties break on id, different createdAt orders ascending. const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', { parentSession: parent.id, createdAt: 9, + origin: 'subagent', }, childEvents(descriptorPayload('late child'))) const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', { parentSession: parent.id, createdAt: 5, + origin: 'subagent', }, childEvents(descriptorPayload('tie b'))) const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', { parentSession: parent.id, createdAt: 5, + origin: 'subagent', }, childEvents(descriptorPayload('tie a'))) - // An ordinary session fork shares parentSession but has no descriptor. + // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) + const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) + expect(listEvents).not.toHaveBeenCalledWith(fork.id) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -233,8 +240,10 @@ describe('SubagentService.listChildren', () => { // A live child session outside persistence: publish a live session with a // descriptor and the parent lineage, without starting an Activation. const liveId = SessionId('live-child') - const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } }) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const live = ctx.sessions.create(liveId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + live.append('turn/start', { turn: 1 }) live.append('subagent/descriptor', descriptorPayload('live child')) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toContainEqual({ @@ -260,6 +269,7 @@ describe('SubagentService.listChildren', () => { events[4] = { ...events[4]!, seq: 4 } const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { parentSession: parent.id, + origin: 'subagent', }, events) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' }) @@ -269,12 +279,13 @@ describe('SubagentService.listChildren', () => { }) }) - it('diagnoses an invalid child event surface as corrupt', async () => { + it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => { const { ctx, parent } = await setup([]) - // The surface-eligible user/message lacks its required surfaceOp, so the - // per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE. + // The surface-eligible user/message lacks its required surfaceOp. The + // first-party persistence inspection rejects before session-query can fold it. const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', { parentSession: parent.id, + origin: 'subagent', }, [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { @@ -293,6 +304,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', { parentSession: parent.id, + origin: 'subagent', }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 })) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }]) @@ -302,6 +314,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', { parentSession: parent.id, + origin: 'subagent', }, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1))) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }]) @@ -315,6 +328,7 @@ describe('SubagentService.listChildren', () => { await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, seedLength: seed.length, + origin: 'subagent', }, seed) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([]) @@ -324,6 +338,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', { parentSession: parent.id, + origin: 'subagent', }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', @@ -354,16 +369,30 @@ describe('SubagentService.listChildren', () => { expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) }) - it('maps a mid-scan disappearance to unavailable', async () => { + it.each([ + ['session', 'SESSION_QUERY_SESSION_NOT_FOUND'], + ['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'], + ] as const)('maps a missing child %s to unavailable', async (_target, code) => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'vanishing child') const query = ctx.get('sessionQuery')! query.listEvents = () => - Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND')) + Promise.reject(new SessionQueryError('gone', code)) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) }) + it('maps an invalid child surface to corrupt', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const childId = await startChild(ctx, parent, 'invalid surface') + const query = ctx.get('sessionQuery')! + query.listEvents = () => + Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE')) + + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) + }) + it('diagnoses a read whose header no longer names this parent as corrupt', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'reparented child') @@ -429,6 +458,7 @@ describe('SubagentService.listChildren', () => { const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', { parentSession: parent.id, createdAt: 1, + origin: 'subagent', }, childEvents(descriptorPayload('twin child'))) // The compacted twin: a compaction checkpoint replaces the whole surface, // while the append-only log retains the model-hidden descriptor event. @@ -447,6 +477,7 @@ describe('SubagentService.listChildren', () => { const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', { parentSession: parent.id, createdAt: 2, + origin: 'subagent', }, compactedEvents) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([ diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index 16e8b58264..d6bd2d78b9 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca -README.zh.md: b82f59ce89690f449115690354f07d5d18e9bed5 +README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69 diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index b82f59ce89..3b989fca8b 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -4,7 +4,7 @@ 可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 -本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。 +本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。 `list_agents` 不接受参数,会从调用它的 agent 推导 parent id,并且不使用 cursor,将 `ctx.subagents.listChildren()` 的结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。 @@ -36,7 +36,7 @@ #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 列表结果 @@ -54,7 +54,7 @@ ## 已知限制与暂缓事项 -- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent Session,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。 -- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。 -- **列表是快照,而非投递承诺**:它可能与发布、dispose 或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。 +- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。 +- **不对当前轮次进行 steering(中途引导)**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。 +- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。 - **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。 diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 087928dbd8..f0d57c52aa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 5406a4dcc4..95cf4e4183 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -66,7 +66,7 @@ export function apply(ctx: Context): void { SessionId(args.subagent_id), message, { - source: { kind: 'coordinator', senderSessionId: parent.id }, + source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id }, signal: exec.signal, }, ) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index da6a083066..302e053abe 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -102,6 +102,7 @@ describe('dsh-tool-subagent-control', () => { // Durable provenance records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ kind: 'coordinator', + form: 'relay', senderSessionId: parent.id, }) }) @@ -129,7 +130,6 @@ describe('dsh-tool-subagent-control', () => { : []) // A follow-up is its own later turn, never steering inside the first one. expect(prompts).toEqual(['long work', 'also consider Y']) - expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) }) it('reports a delivery failure as an errored, not-delivered result', async () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 869dee55bf..1b10e2628a 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 -README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 +README.zh.md: 501598ec4e731b9315f3f08e0c81f5bb655c2b34 diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 4b31bed48e..501598ec4e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 +可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 -`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 +`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。 @@ -46,7 +46,7 @@ #### 模型看到的内容 -一条用户角色的父级消息,以 `Background subagent <child-id> reported:` 开头,后接子级准确的 `output`,并带有持久化来源 `{ kind: 'subagent-report', senderSessionId: <child-id> }`。 +一条用户角色的父级消息,以 `Background subagent <child-id> reported:` 开头,后接子级未经改动的 `output`,并带有持久化来源 `{ kind: 'subagent-report', senderSessionId: <child-id> }`。 #### Token 影响 @@ -58,7 +58,7 @@ ## 已知限制与暂缓事项 -- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。 +- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 - **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。 diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 5b6d3c21d6..12e39c341c 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 84f33646bb..64c29d5122 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -96,14 +96,15 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig }) } -/** Reports durably visible in one Agent's Session. */ +/** Reports already visible or still pending in one Agent. */ function reports(agent: Agent): { id: string; text: string; sender: string }[] { - return agent.session.events.flatMap((event) => { - if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return [] + const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + return [...visible, ...agent.inbox.nextStep].flatMap((message) => { + if (message.source.kind !== 'subagent-report') return [] return [{ - id: event.data.id, - text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'), - sender: event.data.source.senderSessionId, + id: message.id, + text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'), + sender: message.source.senderSessionId, }] }) } @@ -163,8 +164,10 @@ describe('dsh-tool-subagent-report', () => { const { started, child } = await startChild(ctx, parent) const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] - ctx.on('agent/inbox/enqueue', (agent, item) => { - if (agent === parent) enqueues.push(item.placement) + ctx.on('agent/inbox/inserted', (agent, item) => { + if (agent === parent) { + enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + } }) const result = await callReport(ctx, child, 'CHILD_FINDING') @@ -178,7 +181,7 @@ describe('dsh-tool-subagent-report', () => { text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`, sender: started.childId, }]) - expect(enqueues).toEqual([]) + expect(enqueues).toEqual(['steering']) expect(parent.status).toBe('idle') expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests) }) @@ -187,8 +190,10 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] - ctx.on('agent/inbox/enqueue', (agent, item) => { - if (agent === parent) enqueues.push(item.placement) + ctx.on('agent/inbox/inserted', (agent, item) => { + if (agent === parent) { + enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + } }) const result = await callReport(ctx, child, 'WAKE_UP') @@ -227,9 +232,9 @@ describe('dsh-tool-subagent-report', () => { expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false) expect(reports(parent)).toEqual([]) - // The intermediate parent's turn is open, so quiet context is staged until - // that turn reaches its next safe log boundary. - expect(reports(child)).toEqual([]) + // The intermediate parent's turn is open, so quiet context is pending in + // its inbox until that turn reaches its next safe log boundary. + expect(reports(child)).toHaveLength(1) adapter.release() await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) }) expect(reports(child)[0]?.sender).toBe(grandchildStart.childId) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index ae4ed74775..a3bfacedda 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a -README.zh.md: 9e5c16ebc4760744c41965525e871da28b789612 +README.zh.md: 13ff7ee6fddd25c062c08b0b18c94323824f54b9 diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 9e5c16ebc4..13ff7ee6fd 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -6,11 +6,11 @@ ## 提供方选择与生命周期 -每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:全新子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 +每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose 都 reject,出错的结果会保留两项 diagnostic。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 -设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时兑现:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript 即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -29,7 +29,7 @@ ## 并发 -前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 ## 模型体验 @@ -55,17 +55,17 @@ #### Token 影响 -提示词和结果会留在父级历史中,直到上下文压缩(compaction);子 agent 工作上下文留在子 agent 中。 +提示词和结果会留在父级历史中,直到上下文压缩(context compaction);子 agent 工作上下文留在子 agent 中。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 后台结果 #### 模型看到的内容 -在配置的可继续模式下,启动时精确返回 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。 #### Token 影响 @@ -73,7 +73,7 @@ #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 353f67ff24..f7d4c4e84e 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5f310fc9b3..9c0cb0c25d 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md -README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b -README.zh.md: 615b492fa7da5b12a7d0cecfe98fc4f1eb94b504 +README.md: 187ea5b778a4bc1f9c3c9121adb18bda11cc7b58 +README.zh.md: dd3a975daec014131877d7b1523810bd932619d8 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 64e4740c7a..187ea5b778 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +This family runs host subprocesses behind an explicit process-lifecycle service. -| Package | ctx key | Role | +| Package | Role | ctx key | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | +| [`subprocess/`](subprocess/README.md) | Defines subprocess launch, stream, termination, and disposal contracts | `ctx.subprocess` | +| [`subprocess-local/`](subprocess-local/README.md) | Implements local process-tree execution | registers on `ctx.subprocess` | -The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. +The service owns process lifetime; each consumer owns what the process does and which defaults apply. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 615b492fa7..dd3a975dae 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -这里集中提供受管子进程树的 spawn 能力:完整指定的 spawn spec,采用 Node 风格、按流划分的 stdio 处置方式(disposition),包括原始管道、inherit、附带 spill 文件的有界尾部保留收集;harness 中所有 spawn 调用方共用的凭据清除机制;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、时限、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[subprocess seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +本家族通过显式的进程生命周期服务运行宿主子进程。 -| 包(package) | ctx 键 | 角色 | +| 包 | 职责 | ctx 键 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | +| [`subprocess/`](subprocess/README.md) | 定义子进程启动、流、终止和 dispose(资源释放)契约 | `ctx.subprocess` | +| [`subprocess-local/`](subprocess-local/README.md) | 实现本地进程树执行 | 注册到 `ctx.subprocess` | -即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 +服务负责进程生命周期;每个消费方负责进程执行的工作以及所应用的默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 64daa32c81..28ebc2c110 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 202fc57080400afcbf5a65c17ea6bc8ac758c96f -README.zh.md: c3a00cfd327c6865eda57cc63b87fd5f40e2b24f +README.md: af9f92db714398dd52c9b5e7aeb64d5af71021da +README.zh.md: da78cbcfbff174eb5fda5b8323fbdc84b50c9997 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 202fc57080..af9f92db71 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F`. `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. @@ -22,7 +22,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix. +- **Windows tree support is best-effort** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index c3a00cfd32..da78cbcfbf 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,9 +6,9 @@ ## 行为(以及设计来源) -- **以适合平台的方式发送信号的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;重新指定父进程并脱离该组的 daemon 仍可能存活,这与所调研工具的局限相同。 +- **以适合平台的方式发送信号的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;重新指定父进程并脱离该组的 daemon 仍可能存活,这与所调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 -- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 @@ -22,7 +22,7 @@ ## 已知限制与暂缓事项 -- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。 +- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..2aa69b82e1 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..88567e6317 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.zh.md: 914ab16b40c688867eb20afc3de60a38fd88d42b diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..914ab16b40 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -13,7 +13,7 @@ - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 -参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 ## 模型体验 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 6771c19651..b7aa6308ad 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 5d0bbe2c76..d484faa24d 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -45,7 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i * `HOME`, locale, and proxy variables survive, so child CLIs run normally; * harness identity never leaks implicitly (a deliberately forwarded * credential or current `DSH_*` fact goes through the spec's explicit `env`, - * which merges after this scrub). Exported as a plain function so spawners + * which merges after this scrub). Both scrubs match case-insensitively: + * Windows environment names are case-insensitive, so a parent `dsh_*` entry + * would otherwise survive and read back as `$env:DSH_*` in the child; + * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. * @returns a fresh environment object safe to hand to a child spawn. @@ -53,7 +56,7 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i export function scrubbedParentEnv(): Record<string, string> { const env: Record<string, string> = {} for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value } return env } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 28a1dce299..d0ef5c9fd6 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -52,20 +52,23 @@ describe('SubprocessService seam', () => { await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) }) - it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + it('scrubbedParentEnv drops credential-shaped and DSH_ names (case-insensitively) but keeps PATH', () => { process.env.DSH_SCRUB_PROBE = 'stale' + process.env.dsh_scrub_probe_lower = 'stale' process.env.SCRUB_PROBE_TOKEN = 'secret' process.env.SCRUB_PROBE_PASSWORD = 'secret' process.env.SCRUB_PROBE_PLAIN = 'visible' try { const env = scrubbedParentEnv() expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.dsh_scrub_probe_lower).toBeUndefined() expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined() expect(env.SCRUB_PROBE_PLAIN).toBe('visible') expect(env.PATH).toBeDefined() } finally { delete process.env.DSH_SCRUB_PROBE + delete process.env.dsh_scrub_probe_lower delete process.env.SCRUB_PROBE_TOKEN delete process.env.SCRUB_PROBE_PASSWORD delete process.env.SCRUB_PROBE_PLAIN diff --git a/packages/support/README.i18n.yaml b/packages/support/README.i18n.yaml index 6adfa91c58..1b392a1729 100644 --- a/packages/support/README.i18n.yaml +++ b/packages/support/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/README.md -README.md: b9550fd54feb36448227faae8485fe8b6dbf4fb0 -README.zh.md: 8eec9b96f621724e1ddc9a70abed069f11cb7fdc +README.md: 15cb82d3d76a1241af8b40e1c8536292618409a9 +README.zh.md: ff8595b47f1a1220912dc77a072463adf74f7bf9 diff --git a/packages/support/README.md b/packages/support/README.md index b9550fd54f..15cb82d3d7 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -1,16 +1,16 @@ -# support/ — dev/test/example infrastructure +# support/ — development and test infrastructure English | [中文](README.zh.md) -Packages that exist to serve development, testing, and the examples rather than to ship as product API. They are real workspace packages (typed, tested, under the coverage gate), but they carry **lower compatibility expectations**: they may change or be removed when the development need behind them does, without the deprecation care a product package would warrant. +These packages support repository development, tests, and examples rather than product APIs. Their compatibility follows the development need they serve. -| Package | Role | ctx key | -|---|---|---| -| `acp-snapshot/` | ACP test kit: shared subprocess/client launcher + snapshot harness, normalizers, and suite factory | (library — imported by ACP e2e and `*.snapshot.ts` suites) | -| `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) | -| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | -| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | -| `llm-mock-server/` | Scriptable OpenAI-compatible HTTP/SSE fault server + CLI for LLM recovery tests | (standalone server and test library) | -| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | +| Package | Role | +|---|---| +| [`acp-snapshot/`](acp-snapshot/README.md) | Provides the ACP snapshot-test toolkit | +| [`agent-loop-testkit/`](agent-loop-testkit/README.md) | Mounts shared prerequisites for AgentLoop tests | +| [`invariants/`](invariants/README.md) | Runs development-time runtime-contract assertions | +| [`loader-smoke/`](loader-smoke/README.md) | Launches Loader-composed applications for smoke tests | +| [`llm-mock-server/`](llm-mock-server/README.md) | Provides a deterministic OpenAI-compatible fault server | +| [`llm-replay/`](llm-replay/README.md) | Replays recorded model responses for keyless tests and demos | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate, while `llm-mock-server` drives real provider adapters through deterministic HTTP/SSE faults. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +A package moves out of `support/` when it gains a product contract and product consumers. diff --git a/packages/support/README.zh.md b/packages/support/README.zh.md index 8eec9b96f6..ff8595b47f 100644 --- a/packages/support/README.zh.md +++ b/packages/support/README.zh.md @@ -1,16 +1,16 @@ -# support/:开发/测试/示例基础设施 +# support/:开发和测试基础设施 [English](README.md) | 中文 -这些包(package)用于开发、测试和示例,而非作为产品 API 发布。它们是实际的工作区包(具备类型、经过测试,并受覆盖率门禁约束),但具有**较低的兼容性预期**:当其背后的开发需求变化时,它们可以改变或被移除,无需像产品包那样谨慎执行弃用流程。 +这些包为仓库开发、测试和示例提供支持,而不是产品 API。其兼容性取决于所服务的开发需求。 -| 包 | 职责 | ctx 键 | -|---|---|---| -| `acp-snapshot/` | ACP(Agent Client Protocol)测试工具包:共享子进程/客户端启动器、快照 harness、规范化器和套件工厂 | (库:由 ACP e2e 和 `*.snapshot.ts` 套件导入) | -| `agent-loop-testkit/` | 为验证具体 agent loop(智能体循环)的测试挂载共享先决条件 | (库:由 AgentLoop 集成测试导入) | -| `invariants/` | 用于开发诊断的运行时事件契约断言 | (监听 `session/*`、`agent/*`) | -| `loader-smoke/` | 共享的真实 Loader 子进程 harness,用于无密钥示例冒烟测试 | (库:由示例 e2e 套件导入) | -| `llm-mock-server/` | 可编程的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)故障服务器与 CLI(命令行界面),用于 LLM(大语言模型)恢复测试 | (独立服务器和测试库) | -| `llm-replay/` | 录制/回放适配器:通过已记录的会话 JSONL 对 `llm/stream` 进行短路处理(无密钥快照测试) | (监听 `llm/stream`) | +| 包 | 职责 | +|---|---| +| [`acp-snapshot/`](acp-snapshot/README.md) | 提供 ACP(Agent Client Protocol)快照测试工具包 | +| [`agent-loop-testkit/`](agent-loop-testkit/README.md) | 为 AgentLoop 测试挂载共享先决条件 | +| [`invariants/`](invariants/README.md) | 运行开发期运行时契约断言 | +| [`loader-smoke/`](loader-smoke/README.md) | 启动由 Loader 组合的应用以执行冒烟测试 | +| [`llm-mock-server/`](llm-mock-server/README.md) | 提供确定性的 OpenAI 兼容故障服务器 | +| [`llm-replay/`](llm-replay/README.md) | 为无密钥测试和演示回放已记录的模型响应 | -`invariants` 是开发支持,但没有环境条件限制:无论在何处注册,它都会运行;默认 `dsh-agent-spine-demo` bundle 无条件挂载它。`agent-loop-testkit` 为手工构建的 AgentLoop 测试集中管理必需服务主干,而不负责其 agent loop 或场景。`llm-replay` 支撑演示和受逐文件覆盖率门禁约束的快照测试层,`llm-mock-server` 则通过确定性 HTTP/SSE 故障驱动真实提供方适配器。`acp-snapshot` 包含 ACP 子进程/客户端边界以及快照 harness、规范化器和套件机制,`loader-smoke` 负责无密钥示例 e2e 套件使用的并列真实 Loader 启动边界。只有当某个包获得文档记载的产品消费方时,它才会从 `support/` 转入产品分组。 +当一个包获得产品契约和产品消费方时,它会移出 `support/`。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 376cea9dcd..9f70a479ca 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: e7988733827ef1d4de67d6d49764a4836e33d17c -README.zh.md: e2466feb5e2025cb99f252b4206bfacb711bccda +README.md: ff8b89437703e0d63542f2a004f9b0929171010e +README.zh.md: 582285363f6556fcc68d783e2ceedc7eb007dafd diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index e798873382..ff8b894377 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,11 +55,11 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the Web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index e2466feb5e..582285363f 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,11 +55,11 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 Web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index e5e715238a..c231591103 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 6771e788b8..c68be45c00 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -52,6 +52,8 @@ const WAIT_POLL_INTERVAL_MS = 10 * `waitForTurnStart` waits for an open durable turn, optionally at or beyond a * specified turn number. `waitForTurnEnd` holds the subprocess open until the * selected session's latest complete raw-JSONL turn boundary is `turn/end`. + * `waitForGoalPhase` waits for the latest durable goal snapshot to reach one phase. + * `waitForInboxMessage` waits for inserted inbox text containing a scenario marker. * `waitForSubagentTurnEnd` waits until one background child has persisted a * closed model-work turn after its own descriptor; child progress has no ACP * update to wait on. @@ -77,7 +79,9 @@ export type InputStep = | { op: 'waitForFile'; path: string; timeoutMs?: number } | { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number } | { op: 'waitForTurnEnd'; timeoutMs?: number } - | { op: 'waitForSubagentTurnEnd'; child?: number; timeoutMs?: number } + | { op: 'waitForSubagentTurnEnd'; child?: number; minimumTurn?: number; timeoutMs?: number } + | { op: 'waitForGoalPhase'; phase: 'active' | 'paused' | 'blocked' | 'complete'; timeoutMs?: number } + | { op: 'waitForInboxMessage'; text: string; timeoutMs?: number } | { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number } | { op: 'waitForEventAfterTurnEnd'; type: string; timeoutMs?: number } | { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } } @@ -301,7 +305,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise (id) => { sessionId = id }, (id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn), (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), - (child, timeoutMs) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs), + (child, timeoutMs, minimumTurn) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs, minimumTurn), + (id, phase, timeoutMs) => waitForPersistedGoalPhase(sessionsRoot, id, phase, timeoutMs), + (id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs), (id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs), (id, type, timeoutMs) => waitForPersistedEventAfterTurnEnd(sessionsRoot, id, type, timeoutMs), ) @@ -377,7 +383,9 @@ async function runStep( setSessionId: (id: string) => void, waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>, waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>, - waitForChildTurnEnd: (child: number, timeoutMs?: number) => Promise<void>, + waitForChildTurnEnd: (child: number, timeoutMs?: number, minimumTurn?: number) => Promise<void>, + waitForGoalPhase: (sessionId: string, phase: string, timeoutMs?: number) => Promise<void>, + waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>, waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>, waitForEventAfterTurnEnd: (sessionId: string, type: string, timeoutMs?: number) => Promise<void>, ): Promise<void> { @@ -461,8 +469,20 @@ async function runStep( return } case 'waitForSubagentTurnEnd': - await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs) + await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs, step.minimumTurn) return + case 'waitForGoalPhase': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForGoalPhase before newSession') + await waitForGoalPhase(sessionId, step.phase, step.timeoutMs) + return + } + case 'waitForInboxMessage': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForInboxMessage before newSession') + await waitForInboxMessage(sessionId, step.text, step.timeoutMs) + return + } case 'waitForTitleAfterTurnEnd': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession') @@ -554,18 +574,72 @@ async function waitForPersistedChildTurnEnd( root: string, child: number, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, + minimumTurn = 1, ): Promise<void> { await vi.waitFor(async () => { const log = (await harvestSessionLogs(root))[child] if (log === undefined || !latestTurnIsClosed(log.content) - || !hasRequestHeaderAfterDescriptor(log.content)) { + || !hasRequestHeaderAfterDescriptor(log.content) + || !hasClosedTurn(log.content, minimumTurn)) { throw new Error( - `snapshot-harness: subagent child #${child} did not persist a closed work turn within ${timeoutMs}ms`, + `snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`, ) } }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } +/** Whether a raw session log contains the requested closed turn. */ +function hasClosedTurn(content: string, turn: number): boolean { + return content.split('\n').filter(Boolean).some((line) => { + const event = JSON.parse(line) as { type?: unknown; data?: { turn?: unknown } } + return event.type === 'turn/end' && event.data?.turn === turn + }) +} + +/** Wait until the latest durable goal snapshot reaches one phase. */ +async function waitForPersistedGoalPhase( + root: string, + sessionId: string, + phase: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise<void> { + await vi.waitFor(async () => { + const content = (await harvestSessionLogs(root)).find(log => log.id === sessionId)?.content + const matched = content?.split('\n').filter(Boolean).some((line) => { + const event = JSON.parse(line) as { type?: unknown; data?: { goal?: { phase?: unknown } } } + return event.type === 'goal/change' && event.data?.goal?.phase === phase + }) ?? false + if (!matched) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist goal phase "${phase}" within ${timeoutMs}ms`) + } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) +} + +/** Wait until an inserted inbox message contains scenario-owned text. */ +async function waitForPersistedInboxMessage( + root: string, + sessionId: string, + text: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise<void> { + await vi.waitFor(async () => { + const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) + const matched = log?.content.split('\n').some((line) => { + if (line.length === 0) return false + const record = JSON.parse(line) as { + type?: unknown + data?: { inserted?: Array<{ content?: Array<{ type?: unknown; text?: unknown }> }> } + } + return record.type === 'agent/inbox/spliced' && record.data?.inserted?.some(message => + message.content?.some(block => block.type === 'text' + && typeof block.text === 'string' && block.text.includes(text))) === true + }) ?? false + if (!matched) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`) + } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) +} + /** Whether a child log contains model work after its own descriptor event. */ function hasRequestHeaderAfterDescriptor(content: string): boolean { const events = content.slice(0, content.lastIndexOf('\n') + 1) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c8434bb8c9..db59c9265b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -161,25 +161,37 @@ export interface Scenario { * test is skipped on Windows; its fixtures stay guarded on every platform. */ posixOnly?: boolean + /** + * Whether the scenario boots a composition that needs a usable `pwsh` + * (the pwsh-tool-turn scenario). The run test is skipped when the suite's + * {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded + * on every platform. + */ + pwshOnly?: boolean } /** * Whether a scenario's run test is skipped for this mode and host: record mode - * skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly} - * scenarios skip on Windows. + * skips authored (non-`recorded`) scenarios, {@link Scenario.posixOnly} + * scenarios skip on Windows, and {@link Scenario.pwshOnly} scenarios skip + * when the caller's `hasPwsh` probe is false. * * @param scenario The scenario whose run test is being registered. * @param recording Whether the suite runs in record mode. * @param platform The running Node platform, injectable for unit coverage. + * @param hasPwsh The caller's pwsh-availability probe; `pwshOnly` scenarios + * skip unless it is true. * @returns True when the scenario's run test must not execute. */ export function scenarioSkipped( scenario: Scenario, recording: boolean, platform: NodeJS.Platform = process.platform, + hasPwsh?: boolean, ): boolean { if (recording && !scenario.recorded) return true - return scenario.posixOnly === true && platform === 'win32' + if (scenario.posixOnly === true && platform === 'win32') return true + return scenario.pwshOnly === true && hasPwsh !== true } /** One stdout expected output selected for a platform run. */ @@ -220,6 +232,11 @@ export interface SnapshotSuiteOptions { * from `$DSH_SNAPSHOT` — env reading stays outside this library. */ mode: 'replay' | 'record' | 'refresh' + /** + * Whether a real `pwsh` executable is available on this host (the probe the + * caller owns; `pwshOnly` scenarios skip when this is not true). + */ + hasPwsh?: boolean } /** One scenario's generated claim on a shared snapshot file. */ @@ -973,8 +990,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows. - it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows; + // `pwshOnly` scenarios skip when the caller's `hasPwsh` probe is false. + it.skipIf(scenarioSkipped(scenario, RECORDING, process.platform, options.hasPwsh))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 58c6afd019..68dc58c770 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -584,6 +584,55 @@ describe('runScenario', () => { expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"') }) + it('waitForInboxMessage holds the app through a matching durable insertion', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { + type: 'agent/inbox/spliced', + seq: 0, + time: 2, + data: { + target: 'next-turn', + start: 0, + inserted: [{ role: 'user', content: [{ type: 'text', text: 'durable marker' }] }], + }, + }, + ], + }], + }) + const result = await runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'marker' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toContain('durable marker') + }) + + it('waitForInboxMessage times out when the session log or matching insertion is absent', { timeout: 20_000 }, async () => { + const absent = await scenario({ prompt: 'hang-until-cancel', persistLogsOnCancel: true }) + await expect(runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] }, + { agent: AGENT, mode: 'replay', fixtureFile: absent.fixtureFile }, + )).rejects.toThrow(/did not persist expected inbox message within 20ms/) + + const unmatched = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }], + }], + }) + await expect(runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] }, + { agent: AGENT, mode: 'replay', fixtureFile: unmatched.fixtureFile }, + )).rejects.toThrow(/did not persist expected inbox message within 20ms/) + }) + it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', @@ -742,6 +791,38 @@ describe('runScenario', () => { )).rejects.toThrow(/did not persist turn\/end within 20ms/) }) + it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => { + const reached = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'goal/change', seq: 1, time: 2, data: {} }, + { type: 'goal/change', seq: 2, time: 3, data: { goal: { phase: 'active' } } }, + ], + }], + }) + const result = await runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForGoalPhase', phase: 'active' }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile: reached.fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toContain('"phase":"active"') + + const missing = await scenario({}) + await expect(runScenario( + { steps: [...boot, { op: 'waitForGoalPhase', phase: 'blocked', timeoutMs: 20 }] }, + { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, + )).rejects.toThrow(/did not persist goal phase "blocked" within 20ms/) + }) + it('waitForSubagentTurnEnd requires a closed child work turn', { timeout: 20_000 }, async () => { const closed = await scenario({ prompt: 'hang-until-cancel', @@ -777,6 +858,16 @@ describe('runScenario', () => { { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile }, ) expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId) + await expect(runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForSubagentTurnEnd', minimumTurn: 2, timeoutMs: 20 }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile }, + )).rejects.toThrow(/subagent child #1 did not persist closed turn 2 within 20ms/) const seedOnly = await scenario({ prompt: 'hang-until-cancel', @@ -810,13 +901,13 @@ describe('runScenario', () => { ], }, { agent: AGENT, mode: 'replay', fixtureFile: seedOnly.fixtureFile }, - )).rejects.toThrow(/subagent child #1 did not persist a closed work turn within 20ms/) + )).rejects.toThrow(/subagent child #1 did not persist closed turn 1 within 20ms/) const missing = await scenario({}) await expect(runScenario( { steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] }, { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, - )).rejects.toThrow(/subagent child #2 did not persist a closed work turn within 20ms/) + )).rejects.toThrow(/subagent child #2 did not persist closed turn 1 within 20ms/) }) it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => { @@ -1011,6 +1102,8 @@ describe('runScenario', () => { [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/], [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/], + [{ op: 'waitForGoalPhase', phase: 'active' }, /waitForGoalPhase before newSession/], + [{ op: 'waitForInboxMessage', text: 'marker' }, /waitForInboxMessage before newSession/], [{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/], [{ op: 'waitForEventAfterTurnEnd', type: 'user/message' }, /waitForEventAfterTurnEnd before newSession/], [{ op: 'cancel' }, /cancel before newSession/], diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6f16844595..d8cb177a16 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -459,6 +459,7 @@ describe('stdoutExpectedVariants', () => { describe('scenarioSkipped', () => { const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false } const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true } + const pwsh: Scenario = { name: 'pwsh-tool', hasModelTurn: true, recorded: false, pwshOnly: true } it('skips authored scenarios only while recording', () => { expect(scenarioSkipped(authored, true, 'linux')).toBe(true) @@ -471,6 +472,13 @@ describe('scenarioSkipped', () => { expect(scenarioSkipped(posix, false, 'darwin')).toBe(false) expect(scenarioSkipped(authored, false, 'win32')).toBe(false) }) + + it('skips pwshOnly scenarios when the host lacks pwsh, and runs them otherwise', () => { + expect(scenarioSkipped(pwsh, false, 'linux', false)).toBe(true) + expect(scenarioSkipped(pwsh, false, 'win32', true)).toBe(false) + expect(scenarioSkipped(pwsh, false, 'linux', true)).toBe(false) + expect(scenarioSkipped(authored, false, 'linux', false)).toBe(false) + }) }) describe('fixtureContext', () => { diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index aa04d85832..d6419f38dd 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index b51a6af3ee..9d8d8ff16c 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/invariants/README.md README.md: 203dbd5ad09f5b1378061fbf9adcff885889eae2 -README.zh.md: 841e7a32f8e2605c25aadef35862a0223046933f +README.zh.md: fb9d15f3d0cd77f8002c6ff1a909e09e8c6c61fa diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index 841e7a32f8..fb9d15f3d0 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于包(package)自有运行时不变量检查的可配置注册表服务。根插件注册 `ctx.invariants`;它不包含产品检查或产品包导入。每个工作区包都发布一个 `./invariant` 配套入口,用于注册其精确 NPM 包名。 +用于包自有运行时不变量检查的可配置注册表服务。根插件注册 `ctx.invariants`;它不包含产品检查或产品包导入。每个工作区包都发布一个 `./invariant` 配套入口,用于注册其精确 NPM 包名。 ## 服务:`InvariantService`(`ctx.invariants`) diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index d52dd0a14d..f41c6bfde6 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml index 8806042fae..c78f65cc91 100644 --- a/packages/support/llm-mock-server/README.i18n.yaml +++ b/packages/support/llm-mock-server/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-mock-server/README.md README.md: a535c086bf688ad48b1a3bb19c7b81da21cbad92 -README.zh.md: e013cc47399da7fdde42c10dfe086ffab9105785 +README.zh.md: d814d8815b38bb34bd0d871d552e6f3ec75e042a diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md index e013cc4739..d814d8815b 100644 --- a/packages/support/llm-mock-server/README.zh.md +++ b/packages/support/llm-mock-server/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -可编脚本的 OpenAI 兼容 HTTP/SSE 服务器,用于在无提供方密钥的情况下测试真实 LLM 适配器、agent loop 和恢复策略。它接受 `POST /chat/completions` 和 `POST /v1/chat/completions`;每个已接受请求按到达顺序消费一个已配置行为。无效 method、path、bearer token 和 JSON 不消费脚本。 +可编脚本的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)服务器,用于在无提供方密钥的情况下测试真实 LLM(大语言模型)适配器、agent loop(智能体循环)和恢复策略。它接受 `POST /chat/completions` 和 `POST /v1/chat/completions`;每个已接受请求按到达顺序消费一个已配置行为。无效的请求方法、路径、Bearer token 和 JSON 不会消费脚本条目。 -库入口导出 `startMockLlmServer(options)`、行为和 telemetry 类型、默认随机压力权重、可接受的 Node timer 边界,以及带有绑定 `baseURL`、已生成或已配置 `randomSeed`、已捕获请求和幂等 `close()` 的运行句柄。关闭会强制终止停滞连接。 +库入口导出 `startMockLlmServer(options)`、行为类型和遥测(telemetry)类型、默认随机压力权重、Node 定时器允许的上限,以及带有绑定 `baseURL`、自动生成或显式配置 `randomSeed`、已捕获请求和幂等 `close()` 的运行句柄。关闭会强制终止停滞连接。 ## 独立使用 @@ -26,7 +26,7 @@ DEEPSEEK_API_KEY=mock-key \ pnpm run demo:headless "test provider recovery" ``` -仓库脚本将 JSONL 写入 stdout:`ready` 记录携带 `/v1` base URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包(package)不公开可安装的二进制命令。 +仓库脚本将 JSONL 写入 stdout:`ready` 记录携带以 `/v1` 结尾的基础 URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包不公开可安装的二进制命令。 ## 行为脚本 @@ -34,23 +34,23 @@ pnpm run demo:headless "test provider recovery" | 行为 | 协议结果 | |---|---| -| `connection_reset` | 在 HTTP header 前销毁 socket | -| `stream_disconnect` | 发送 SSE header,然后在第一个事件前 reset | -| `partial_disconnect` | 发送文本 delta,然后 reset socket | -| `stall` | 发送 SSE header,并保持空闲,直到客户端/服务器取消 | +| `connection_reset` | 在发送 HTTP 标头前销毁 socket | +| `stream_disconnect` | 发送 SSE 标头,然后在第一个事件前重置连接 | +| `partial_disconnect` | 发送文本增量,然后重置 socket | +| `stall` | 发送 SSE header,并保持空闲,直到客户端/服务器取消 | | `empty` | 发送有效的无内容 stop 和 `[DONE]` | | `empty_body` / `stream_eof` / `partial_eof` | 正常结束,但缺少必需的 `[DONE]` 边界 | | `malformed_json` / `malformed_event` | 发送无效 SSE JSON 或无效提供方分片形态 | | `rate_limit` / `server_error` / `service_unavailable` | 返回面向重试的 429/500/503 JSON 错误 | -| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | 返回终止性或单独恢复的提供方错误 | +| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | 返回终止性错误或需要单独恢复的提供方错误 | | `success` / `slow_success` / `reasoning_success` | 流式发送完整文本响应,可选延迟或先发送 reasoning | -| `tool_call_success` / `max_tokens` | 以工具调用或 `length` 结束原因完成 | -| `wrong_content_type` | 在 `application/json` 下发送有效 SSE 正文 | -| `random` | 从加权播种随机性中选择具体请求行为 | +| `tool_call_success` / `max_tokens` | 以工具调用或结束原因 `length` 完成 | +| `wrong_content_type` | 以 `application/json` 内容类型发送有效 SSE 正文 | +| `random` | 按带权重的种子随机选择具体请求行为 | `connection_refused` 只能在 CLI 中使用,且必须是第一个条目。它会延迟绑定调用方指定的非零端口,因此 `--listen-delay-ms` 期间的请求会收到真实 TCP 拒绝;其余条目在 listener 启动后开始。 -## 随机 mode +## 随机模式 使用重复 `random` 条目执行开放式混合运行: @@ -63,24 +63,24 @@ pnpm run mock:llm -- \ --random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5' ``` -省略 `--seed` 会生成种子,并在 `ready` 记录中打印。`--random-weights` 接受非负的相对 `behavior=weight` 条目,并要求至少一个正权重具体行为。导出默认值是一个成功占主导的压力分布,包含 reset、disconnect、部分输出、空完成、stall、429/5xx、干净截断和格式错误 JSON;它用于施加测试压力,而非估计生产事故频率。`connection_refused` 被排除,因为已绑定的请求处理器无法产生真实拒绝。 +省略 `--seed` 会生成种子,并在 `ready` 记录中打印。`--random-weights` 接受非负的相对 `behavior=weight` 条目,并要求至少一个正权重具体行为。导出默认值是一个成功占主导的压力分布,包含 reset、disconnect、部分输出、空完成、stall、429/5xx、干净截断和格式错误的 JSON;它用于施加测试压力,而非估计生产事故频率。`connection_refused` 被排除,因为已绑定的请求处理器无法产生真实拒绝。 随机权重包含 `stall` 时,为待测客户端配置较短的流空闲超时,使场景及时结束。 ## 时序与内容控制 -CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 和 `--tool-arguments`。毫秒延迟是 Node timer 范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的精确 `apiKey` 验证 `Authorization: Bearer <token>`;省略时接受任何 token。 +CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 和 `--tool-arguments`。毫秒延迟是 Node timer 范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `Authorization: Bearer <token>`;省略时接受任何 token。 ## 模型体验 无。该测试服务器替代提供方协议行为,而不调用真实模型。 -#### KV 缓存影响 +#### KV Cache 影响 无;请求在本地终止,绝不会到达提供方缓存。 -## 已知限制与待完成工作 +## 已知限制与暂缓事项 - **随机权重建模测试压力,而非生产事故频率**:需要环境专用分布的调用方必须提供已测量权重,并记录发出的种子。 - **请求脚本按到达顺序执行**:并发调用方共享一个游标,因此确定性的每会话故障分配需要独立服务器实例。 -- **真实连接拒绝是 listener 生命周期阶段**:CLI 延迟必须与客户端尝试重叠;请求级随机选择只能 reset 已接受连接。 +- **真实连接拒绝发生在监听器生命周期阶段**:CLI 延迟必须与客户端尝试重叠;请求级随机选择只能重置已接受的连接。 diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 4b14cb6d0b..20b1cf6d85 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 507c3da1a5..a4729b2e69 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md -README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6 -README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2 +README.md: ee062d0c2804905f33f1ff476d12bb6dd57666e5 +README.zh.md: ab3420d9500a6ca77f04a2ad96095f8883aeb874 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 85aa567059..ee062d0c28 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery. -Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus the web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the web lane installs it directly to retain the teardown consumption handle. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. +Its consumers are the ACP and headless `stream-json` snapshot suites plus the Web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the Web lane installs it directly to retain the teardown consumption handle. ## How the fixture works diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 751f75dea1..ab3420d950 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -用于无密钥快照测试的大语言模型(LLM)回放插件。它根据已记录的**会话 JSONL** fixture(测试前置数据)重建模型流,使测试无需 API 密钥即可针对固定的模型 transcript(文本记录)启动真实 agent(智能体)。配置 `providers` 后,它会注册仅用于回放的适配器,其模型目录可供测试模型发现功能的场景使用;未配置 `providers` 时,它会安装无需模型发现功能的测试所用 catch-all `llm/stream` waterfall(瀑布式事件)。 +用于无密钥快照测试的 LLM(大语言模型)回放插件。它根据已记录的**会话 JSONL** fixture(测试前置数据)重建模型流,使测试无需 API 密钥即可针对固定的模型 transcript(文本记录)启动真实 agent(智能体)。配置 `providers` 后,它会注册仅用于回放的适配器,其模型目录可供测试模型发现功能的场景使用;未配置 `providers` 时,它会安装无需模型发现功能的测试所用 catch-all `llm/stream` waterfall(瀑布式事件)。 -其消费方包括 ACP(Agent Client Protocol)、headless `stream-json` 和 TUI 快照套件,以及 Web 浏览器 e2e 流水线。Loader 驱动的套件使用此插件替代真实 LLM 适配器;Web 流水线直接安装它,以保留清理阶段的消费检查句柄。将派生和回放逻辑放在此处,可使其受 `packages/*/src` 的逐文件 100% 覆盖率门禁约束。 +其消费方包括 ACP(Agent Client Protocol)与 headless `stream-json` 快照套件,以及 Web 浏览器 e2e 流水线。Loader 驱动的套件使用此插件替代真实 LLM 适配器;Web 流水线直接安装它,以保留清理阶段的消费检查句柄。 ## fixture 的工作方式 @@ -16,16 +16,16 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as ## 嵌套 agent:每会话键控 -父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 +父 agent 委托给进程内 subagent 的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 -回放根据发起调用的会话 id 为每次调用建立键(`GenerateOptions.sessionId` 由 agent loop 写入)。实时会话 id 每次运行时都会重新随机生成,绝不会等于记录中的 id,因此实时会话按**首次调用顺序**绑定到已记录脚本:脚本按 header 中的 `createdAt` 排序(父会话在前,因为它必须先开始流式输出才能委托);第一个发起调用的实时会话取得第一个脚本,下一个新会话取得下一个脚本,以此类推。此后每个会话分别推进自己的游标。没有 `sessionId` 的调用视为一个绑定主脚本的匿名会话,因此单会话场景的行为与以前完全相同。不同实时会话的数量超过已记录脚本数时会明确报错。 +回放根据发起调用的会话 id 为每次调用建立键(`GenerateOptions.sessionId` 由 agent loop(智能体循环)写入)。实时会话 id 每次运行时都会重新随机生成,绝不会等于记录中的 id,因此实时会话按**首次调用顺序**绑定到已记录脚本:脚本按 header 中的 `createdAt` 排序(父会话在前,因为它必须先开始流式输出才能委托);第一个发起调用的实时会话取得第一个脚本,下一个新会话取得下一个脚本,以此类推。此后每个会话分别推进自己的游标。没有 `sessionId` 的调用视为一个绑定主脚本的匿名会话,因此单会话场景的行为与以前完全相同。不同实时会话的数量超过已记录脚本数时会明确报错。 ## 配置 | 键 | 类型 | 默认值 | 说明 | |---|---|---|---| | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | -| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` sidecar:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | | `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | @@ -55,9 +55,9 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as ## 导出项 - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 -- `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 +- `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 @@ -70,7 +70,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as #### KV Cache 影响 -无;该包(package)既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供方请求。 ## 已知限制与暂缓事项 diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 4a7e7dd8d8..708e84b57a 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 4b89a844bf..ae62843492 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -173,9 +173,10 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe /** * Reconstruct the per-`stream()` replay script from a recorded session log. * - * Groups `assistant/chunk` events by turn and step. Every group must end in a - * `finish`; a missing terminator means the live stream threw, so derivation - * rejects and the scenario must provide an explicit override. + * Splits `assistant/chunk` events at every `finish`, using turn and step changes + * to detect an unterminated prior call. A missing terminator means the live + * stream threw, so derivation rejects and the scenario must provide an explicit + * override. Multiple calls may share one turn and step when the loop retries. * @param events - the recorded session's events; only `assistant/chunk` is consulted. * @returns one `chunks` entry per recorded model call, in call order. */ @@ -197,14 +198,16 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { if (event.type !== 'assistant/chunk') continue const { turn, step, chunk } = event.data const key = `${turn}/${step}` - if (key !== currentKey) { - // A new (turn, step) — i.e. a new stream() call. Close the previous one - // (skip the initial empty buffer before any chunk has been seen). + if (current.length > 0 && key !== currentKey) { close(currentKey, current) - currentKey = key + } + if (current.length === 0) currentKey = key + current.push(chunk) + if (chunk.type === 'finish') { + close(currentKey, current) + currentKey = undefined current = [] } - current.push(chunk) } close(currentKey, current) return script diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 8113dbc922..0b7d87ad13 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -107,11 +107,27 @@ describe('parseSessionLog', () => { }) describe('deriveReplayScript', () => { - it('groups assistant/chunk by (turn, step) into one entry per stream() call', () => { + it('groups one finished assistant/chunk stream into one replay entry', () => { const events: SessionEvent[] = TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)) expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) }) + it('separates retry calls that share one turn and step at their finish chunks', () => { + const failed: StreamChunk[] = [ + { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'empty', code: 'EMPTY_RESPONSE' } } }, + ] + let seq = 1 + const events: SessionEvent[] = [ + ...failed.map(chunk => chunkEvent(seq++, 1, 1, chunk)), + ...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 1, chunk)), + ] + expect(deriveReplayScript(events)).toEqual([ + { kind: 'chunks', chunks: failed }, + { kind: 'chunks', chunks: TEXT_CHUNKS }, + ]) + }) + it('produces one entry per distinct (turn, step), in log order', () => { const callA = TEXT_CHUNKS const callB: StreamChunk[] = [ @@ -142,7 +158,7 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1 } }, ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ] @@ -167,7 +183,7 @@ describe('deriveReplayScript', () => { const events: SessionEvent[] = [ chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }), - { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'x' } } }, + { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } }, ] expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s) }) @@ -178,6 +194,14 @@ describe('deriveReplayScript', () => { ] expect(() => deriveReplayScript(events)).toThrow(/2\/3/) }) + + it('rejects an unfinished call before consuming chunks from a new step', () => { + const events: SessionEvent[] = [ + chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(2, 1, 2, { type: 'finish', reason: { kind: 'stop' } }), + ] + expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/) + }) }) describe('loadReplayScript', () => { diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index ebdd62373a..1eeabb950d 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/tasks/README.i18n.yaml b/packages/tasks/README.i18n.yaml index 18c81fdaed..38cd47299c 100644 --- a/packages/tasks/README.i18n.yaml +++ b/packages/tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/README.md -README.md: 9bafe5633bb7e57a5404ffb41fad04b621832b6d -README.zh.md: f07990f45a636314da358c4f39a72d70ce51db1a +README.md: 05ed9c439684337c45abe11f03c861a1b795b2ac +README.zh.md: 16fe6c0a2b183b719cb6aa8ac068e34d51832a2b diff --git a/packages/tasks/README.md b/packages/tasks/README.md index 9bafe5633b..05ed9c4396 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -1,13 +1,13 @@ -# tasks/ — background task capability family +# tasks/ — background-task capability family English | [中文](README.zh.md) -The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and the [task-registry seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md). +This family gives long-running tools one owner-isolated background-task protocol for observation, cancellation, waiting, and completion notices. -| Package | ctx key | Role | +| Package | Role | ctx key | |---|---|---| -| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry seam: branded `<kind>-N` ids, the owner-fenced read/kill/wait/list contract, snapshot vocabulary, the `attachSurface` misconfiguration fence, and the snapshot invariant companion | -| [`tasks-local`](tasks-local/README.md) (`@deepseek-ai/dsh-tasks-local`) | — | The process-local registry implementation: in-memory records, first-wins settlement bookkeeping, and the awaited owner-cleanup and teardown paths | -| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section | +| [`tasks/`](tasks/README.md) | Defines the task registry and lifecycle contract | `ctx.tasks` | +| [`tasks-local/`](tasks-local/README.md) | Implements the process-local task registry | registers on `ctx.tasks` | +| [`tool-tasks/`](tool-tasks/README.md) | Exposes task control and completion notices to the model | registers on `ctx.tools` | -The registry owns state across producer or surface reloads; the tool package owns presentation. Producers register execution hooks through `ctx.tasks.start` and own whether their config exposes `run_in_background`. +See the [background-task runtime](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and [task-registry](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md) decisions. diff --git a/packages/tasks/README.zh.md b/packages/tasks/README.zh.md index f07990f45a..16fe6c0a2b 100644 --- a/packages/tasks/README.zh.md +++ b/packages/tasks/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这是后台任务 id、所有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent(子 agent)及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。 +本家族为长时间运行的工具提供一套按所有者隔离的后台任务协议,用于观察、取消、等待和完成通知。 -| 包(package) | ctx 键 | 角色 | +| 包 | 职责 | ctx 键 | |---|---|---| -| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表 seam:带品牌类型的 `<kind>-N` id、按所有者隔离的读取/终止/等待/列出契约、快照词汇、`attachSurface` 配置错误防线,以及快照不变式配套项 | -| [`tasks-local`](tasks-local/README.md)(`@deepseek-ai/dsh-tasks-local`) | 无 | 进程内注册表实现:内存记录、以首次结算为准的簿记,以及会等待执行完毕的所有者清理路径和销毁路径 | -| [`tool-tasks`](tool-tasks/README.md)(`@deepseek-ai/dsh-tool-tasks`) | 无 | 面向模型的控制接口:`task_output`、`task_list`、`task_kill`、完成通知注入和后台工作习惯提示词段落 | +| [`tasks/`](tasks/README.md) | 定义任务注册表和生命周期契约 | `ctx.tasks` | +| [`tasks-local/`](tasks-local/README.md) | 实现进程本地任务注册表 | 注册到 `ctx.tasks` | +| [`tool-tasks/`](tool-tasks/README.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` | -生产方或接口重载时,状态仍由注册表持有;工具包负责呈现。生产方通过 `ctx.tasks.start` 注册执行钩子,并自行决定其配置是否公开 `run_in_background`。 +参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)决策。 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index cdcc823826..446b5825d3 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index fb8ac03c23..29d859760f 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -18,20 +18,20 @@ const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>() function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) + const session = Session.create(id) const agent = { id, options: {}, - session: new Session(id), + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle' as const, - acceptsNextStep: false, ctx: scopeFiber.ctx, + send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, - send: () => {}, - updateInbox: (): 'not-found' => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, + runMaintenance: <T>(task: (signal: AbortSignal) => Promise<T>) => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 97dc186a8e..7ceb8ff432 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md README.md: 2f822bad139020f0ebae0165aa4e8893853f635d -README.zh.md: 19f77347896fc9da631c819ea4086cc24ad5f1a8 +README.zh.md: fdc619fbb46267b2ae550c85cb14fcf8a916f638 diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index 19f7734789..fdc619fbb4 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -20,7 +20,7 @@ 实现还必须兑现契约的生命周期语义:注册的存续期长于生产方 fiber 与控制表层 fiber,owner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。 -参见[任务类型目录](../../../docs/core-data-structures/tasks.md)、[运行时 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。 +参见[任务类型目录](../../../docs/core-data-structures/tasks.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-task-registry-seam.md)。 ## 模型体验 diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 9bc02879cf..eea8e2af6b 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 646ac8ca6c..bdd840f0bd 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md -README.md: 276ca3f8284b366bee54e585297c3a033a65c8d1 -README.zh.md: 823c25c3e0c7c208ffef9c5be5b2e4ef7e96bad8 +README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 +README.zh.md: 9e54e2de69f36d5d5b70094cab12e5503eaeb5fd diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 276ca3f828..6e8e889c23 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -18,7 +18,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` ## Completion notices -An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. An owner-disposal race needs no special handling: the loop has no terminal state, so a notice injected during teardown appends as idle context — persisted for resume while the session is still attached, dropped with the detached log afterwards. +An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. ## Config diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 823c25c3e0..9e54e2de69 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -18,7 +18,7 @@ ## 完成通知 -一项尚未报告的完成会向精确 owner 的会话注入 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.`。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是下一次请求使用的持久上下文,并非唤醒。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。owner 释放竞态无需特殊处理:循环没有终结状态,teardown 期间注入的通知作为空闲上下文追加——会话仍挂接时随之持久化以供恢复,之后随脱离挂接的日志一并丢弃。 +一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到精确 owner 的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。 ## 配置 diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index c63e49ddc9..dfffbf4855 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 0491551e20..5372fffdb2 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -114,6 +114,15 @@ function fitWithSuffix( return `${retainTail(content, maxBytes - fixedBytes)}${fixed}` } +/** + * One-line account of a settled task for the `notice` form's collapsed row. + * @param snapshot - the settled task. + * @returns its kind, label, and status, bounded like every notice summary. + */ +function completionSummary(snapshot: TaskSnapshot): string { + return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`) +} + function fitCompletionNotice(snapshot: TaskSnapshot): string { const prefix = `background task ${snapshot.id}` const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}` @@ -218,11 +227,9 @@ export function apply(ctx: Context, config: Config): void { }) // Use the exact lifecycle owner; reusable ids could resolve to a replacement. - // Delivery into a tearing-down owner is well-defined: the loop treats - // disposal like any cancel, so the notice appends as durable idle context - // (still attached and persisted during owner cleanup, presented on resume); - // after detach it lands in an unreferenced in-memory log and is dropped - // with it. + // Delivery targets the exact lifecycle owner. The notice waits in its + // next-step inbox until another step claims it; disposal before that + // boundary discards it with the owner. ctx.tasks.onTaskDone((snapshot, owner) => { if (snapshot.reported || owner === undefined) return owner.inject(createUserMessage({ @@ -230,7 +237,12 @@ export function apply(ctx: Context, config: Config): void { type: 'text', text: fitCompletionNotice(snapshot), }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: completionSummary(snapshot), + }, })) }) diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index c04bca5295..0f1bce7ecf 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -460,7 +460,12 @@ describe('completion notices', () => { id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: 'bash pnpm test [status: completed, exit code: 0]', + }, }) }) @@ -484,7 +489,14 @@ describe('completion notices', () => { id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + // The label and status detail are unbounded caller text, so the durable + // one-line account caps itself rather than committing their full length. + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: `subagent ${'x'.repeat(110)}…`, + }, }, ) diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index 41f1bd956f..0d2a11ab23 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/telemetry/README.md -README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d -README.zh.md: 795b20abb47e1bf791730cc7f3ebb0522549a271 +README.md: c390493d4053f9f532c30c3c2291d833554d6a99 +README.zh.md: 846a3e276aeaeda7e3456f4e4d4bea577d224a89 diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 944cb3f9ba..c390493d40 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,10 +1,12 @@ -# telemetry/ +# telemetry/ — session telemetry capability family English | [中文](README.zh.md) -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +This family projects session activity into outbound telemetry and delegates delivery to a configured reporting backend. | Package | Role | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | +| [`session-telemetry/`](session-telemetry/README.md) | Defines capture, redaction, projection, and backend delivery | +| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs | + +The [telemetry decision](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records the reporting boundary. diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index 795b20abb4..846a3e276a 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -1,10 +1,12 @@ -# telemetry/ +# telemetry/:会话遥测能力家族 [English](README.md) | 中文 -面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计固定在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中:边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 +本家族将会话活动投影为外发遥测,并将投递委派给配置的上报后端。 -| 包(package) | 职责 | +| 包 | 职责 | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:OTel JS SDK 的日志流水线(`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器),经透传(passthrough)原样配置。 | +| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影和后端投递 | +| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志投递遥测 | + +[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了上报边界。 diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index fcd57049fc..f9e2abeabd 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md -README.md: 3abd97187cafee132823c02a0b0d103a86bda7db -README.zh.md: 223e6a663933da81032a1fbbb4211555c4bcc159 +README.md: 3a5d2b3a4b2adfb591cd4e18908f72ed5492fca4 +README.zh.md: 50ef72c92800943266ca6ecdb4483dc65ed58d79 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 3abd97187c..3a5d2b3a4b 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -39,4 +39,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. -- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. +- **Live-collector behavior belongs to the SDK exporter** — authentication, TLS, throttling, and other real OTLP deployment behavior follow the upstream SDK rather than a package-owned compatibility layer. diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 223e6a6639..50ef72c928 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -39,4 +39,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`; ## 已知限制与暂缓事项 - **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。 -- **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。 +- **真实 collector 行为属于 SDK 导出器**:身份验证、TLS、限流及其他真实 OTLP 部署行为遵循上游 SDK,不由本包自有兼容层处理。 diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 49e9ef6cad..45de52bf3c 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index ae3dda5145..14839f3a3c 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -112,8 +112,8 @@ describe('TelemetryOtel wire', () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) @@ -166,7 +166,7 @@ describe('TelemetryOtel wire', () => { processor: { scheduledDelayMillis: 10 }, }) const session = ctx.sessions.create(SessionId('drain'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await arrived.promise const disposal = fiber.dispose() @@ -197,7 +197,7 @@ describe('TelemetryOtel wire', () => { shutdownTimeoutMillis: 50, }) const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await arrived.promise const started = performance.now() @@ -223,7 +223,7 @@ describe('TelemetryOtel wire', () => { exporter: { url, compression: 'gzip' }, } as Config) const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) @@ -238,7 +238,7 @@ describe('TelemetryOtel wire', () => { const { ctx, fiber } = await boot(url) ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' })) const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) // No flush(): the coordinator's optional-call forwarding no-ops, and the // batch processor owns export cadence end to end (see the backend note). expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 18f6751424..28aa3192a7 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2 -README.zh.md: e6f077c1d12d00e746147908560d05381fde11c3 +README.zh.md: 6a72389135b3f4009625f7448f2774f239b804b5 diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index e6f077c1d1..6a72389135 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 +遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 ## 后端契约 diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json index 71646c130e..ff6e31b025 100644 --- a/packages/telemetry/session-telemetry/package.json +++ b/packages/telemetry/session-telemetry/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index f56f433a64..02ca434c0d 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -70,7 +70,7 @@ function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2) } function appendTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -108,7 +108,7 @@ describe('TelemetryCoordinator capture', () => { it('maps outcome flags to severity, unknown types falling through as info', async () => { const { ctx, backend } = await setup() const session = liveSession(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ @@ -126,7 +126,7 @@ describe('TelemetryCoordinator capture', () => { }), }, { surfaceOp: 'append' }) session.append('telemetry-test/opaque', { payload: { nested: [] } }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } }) const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) expect(severities).toEqual([ ['turn/start', 'info'], @@ -196,7 +196,7 @@ describe('TelemetryCoordinator adoption', () => { const ctx = new Context() await ctx.plugin(SessionStore) const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) - donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) await ctx.plugin({ @@ -265,7 +265,7 @@ describe('TelemetryCoordinator adoption', () => { const backend = new FakeBackend() const { ctx, fiber } = await setup(backend) const session = liveSession(ctx, 'hmr') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) expect(backend.ledger()).toHaveLength(2) @@ -412,7 +412,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const session = liveSession(ctx) backend.emitError = new Error('backend broke') - expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() + expect(() => session.append('turn/start', { turn: 1 })).not.toThrow() expect(warn).toHaveBeenCalled() backend.emitError = undefined session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) diff --git a/packages/timeout/README.i18n.yaml b/packages/timeout/README.i18n.yaml index c2637365b8..33a7a5b26d 100644 --- a/packages/timeout/README.i18n.yaml +++ b/packages/timeout/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/timeout/README.md -README.md: 2a75e4d54aa1f518858d97e2f4c7d09a23a80a70 -README.zh.md: d0192f7441eb212afa3752cf703877d8d9b58e5c +README.md: d9d8e8a829d8d466d1cc3e42a6c32ac4b3db428b +README.zh.md: c5920258ade7022fb5b28a351ba060eb56e8bc3c diff --git a/packages/timeout/README.md b/packages/timeout/README.md index 2a75e4d54a..d9d8e8a829 100644 --- a/packages/timeout/README.md +++ b/packages/timeout/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -The tool-call timeout policy plugin. A single **product** package: it is a deployment-policy consumer of the `tools/execute` around-dispatch seam (owned by [`dsh-tools`](../core/tools)) and the pure [`dsh-timeout`](../util/timeout) library — not a swappable capability with an interface/implementation split, so it needs no seam trio. +This group applies deployment-configured deadlines to model-facing tool calls. Capabilities remain responsible for terminating their own work. -| Package | Role | ctx key | -|---|---|---| -| `timeout-policy/` | A `tools/execute` wrapper: for each configured tool it arms a per-call deadline on `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline wins | (registers a `tools/execute` listener; injects nothing) | +| Package | Role | +|---|---| +| [`timeout-policy/`](timeout-policy/README.md) | Enforces configured per-tool call deadlines | -Timeout is split across three layers: [`dsh-timeout`](../util/timeout) owns the pure timing/classification primitive (`deadline`/`timeoutOf`), each capability owns termination (bash kills its process group, the fetch provider tears down its socket), and this package owns the *model-facing tool-call budget as deployment policy* — no model-facing timeout argument, no global default. It is the middleware the [timeout-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) foresaw. `bash` and hook command execution keep their own `BASH_TIMEOUT` backend timeout and do not route through this policy. +The pure timing primitives live in [`util/timeout`](../util/timeout/README.md). See the [timeout-library decision](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md). diff --git a/packages/timeout/README.zh.md b/packages/timeout/README.zh.md index d0192f7441..c5920258ad 100644 --- a/packages/timeout/README.zh.md +++ b/packages/timeout/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -工具调用超时策略插件。它是单一 **产品**包(package):它是 `tools/execute` 环绕分发 seam(由 [`dsh-tools`](../core/tools) 拥有)和纯 [`dsh-timeout`](../util/timeout) 库的部署策略消费方,而非带接口/实现拆分的可替换能力,因此无需 seam 三包组合。 +本分组将部署配置的截止时间应用于面向模型的工具调用。各项能力仍负责终止自身工作。 -| 包 | 职责 | ctx 键 | -|---|---|---| -| `timeout-policy/` | `tools/execute` 包装层:对每个已配置工具,它都在 `exec.signal` 上设置单次调用截止时间,并在截止时间先到时返回结构化 `TOOL_TIMEOUT` 结果 | (注册 `tools/execute` 监听器;不注入任何内容) | +| 包 | 职责 | +|---|---| +| [`timeout-policy/`](timeout-policy/README.md) | 强制执行配置的逐工具调用截止时间 | -超时被拆分为三层:[`dsh-timeout`](../util/timeout) 拥有纯计时/分类原语(`deadline`/`timeoutOf`);每种能力拥有终止操作(bash 终止其进程组,fetch 提供方关闭其 socket);本包则拥有 *作为部署策略的面向模型工具调用预算*:没有面向模型的超时参数,也没有全局默认值。它是[超时库 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 所预见的中间件。`bash` 和钩子命令执行保留各自的 `BASH_TIMEOUT` 后端超时,不经过此策略。 +纯计时原语位于 [`util/timeout`](../util/timeout/README.md)。参见[超时库决策](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。 diff --git a/packages/timeout/timeout-policy/README.i18n.yaml b/packages/timeout/timeout-policy/README.i18n.yaml index 94509e3669..377d2c528d 100644 --- a/packages/timeout/timeout-policy/README.i18n.yaml +++ b/packages/timeout/timeout-policy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/timeout/timeout-policy/README.md README.md: 3e5769e2f8b95392e9d489659c6bae634ab3ff18 -README.zh.md: 8d69e7d4aacb70beb5eaaa63c74a0759e0038ec5 +README.zh.md: bfba62cdfae8bb956aec43bcf6e8df12407d8ba2 diff --git a/packages/timeout/timeout-policy/README.zh.md b/packages/timeout/timeout-policy/README.zh.md index 8d69e7d4aa..bfba62cdfa 100644 --- a/packages/timeout/timeout-policy/README.zh.md +++ b/packages/timeout/timeout-policy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -工具调用超时强制执行器:单个 `tools/execute` 环绕分发监听器,会在 `exec.signal` 上设置单次调用的协作式截止时间;适用于声明了 `timeoutMs` 且声明位于其 `ToolDefinition` 上的工具。该截止时间先到时,它返回结构化 `TOOL_TIMEOUT` 结果。预算从工具自身的声明中读取(`ToolDefinition.timeoutMs`,由拥有该工具的插件设置),因此此插件是**零配置**的。它是 `tools/execute` 包装层的参考实现,也是面向模型工具调用预算的强制执行归属地(超时库 Agent Note(agent 决策记录)所预见的中间件)。 +工具调用超时强制执行器:单个 `tools/execute` 环绕分发监听器,会在 `exec.signal` 上设置单次调用的协作式截止时间;适用于声明了 `timeoutMs` 且声明位于其 `ToolDefinition` 上的工具。该截止时间先到时,它返回结构化 `TOOL_TIMEOUT` 结果。预算从工具自身的声明中读取(`ToolDefinition.timeoutMs`,由拥有该工具的插件设置),因此此插件是**零配置**的。它是 `tools/execute` 包装层的参考实现,也是面向模型工具调用预算的强制执行归属地(超时库 Agent Note 所预见的中间件)。 ## 插件(命名空间:`timeout-policy`) @@ -49,7 +49,7 @@ #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/timeout/timeout-policy/package.json b/packages/timeout/timeout-policy/package.json index 43dc9dfa28..b77bd23716 100644 --- a/packages/timeout/timeout-policy/package.json +++ b/packages/timeout/timeout-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index 859de5e095..c75e09350a 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/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/todo/README.md -README.md: da85a5573507cd8bb1ac52f9614225819a479baf -README.zh.md: 5b83fe4d2d59ff6401b6a455d8d5977d6a5e5122 +README.md: 66abf18131ee53d87e933757c40117841e94b07f +README.zh.md: 38a6b3c22653b8e22cc0b693eb666620513a3d8c diff --git a/packages/todo/README.md b/packages/todo/README.md index da85a55735..66abf18131 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -The model-facing todo tool. A single **product** package — there is no interface/implementation seam here, because the list is single-owner session state (one agent session owns its own list), not a swappable capability. +The model-facing todo capability. It is a single **product** package because one agent session owns the list; there is no replaceable provider seam. | Package | Role | ctx key | |---|---|---| -| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | +| [`tool-todo/`](tool-todo/README.md) | Stores and exposes the session's todo list. | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. Host/client runtimes render the durable list from session events. +The child README owns the tool, persistence, and rendering contract. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index 5b83fe4d2d..38a6b3c226 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -面向模型的 todo 工具。它是单一 **产品**包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 +面向模型的 todo 能力。它是单一**产品**包,因为一个 agent(智能体)会话拥有该列表;不存在可替换的提供方 seam。 | 包 | 职责 | ctx 键 | |---|---|---| -| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | +| [`tool-todo/`](tool-todo/README.md) | 存储并公开会话的 todo 列表。 | (注册到 `ctx.tools`) | -列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。宿主/客户端运行时会根据会话事件渲染该持久化列表。 +子级 README 负责工具、持久化和渲染契约。 diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 85bd18ea8b..0fa3eb0c4f 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index abfcd74b29..f186711845 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -47,7 +47,7 @@ describe('todo snapshot invariants', () => { expect(() => { ctx.emit('tools/change') ctx.emit('session/event', {} as Session, { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) }).not.toThrow() }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index ede937801d..d751d2a949 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -97,9 +97,9 @@ describe('todos projection provider', () => { seedMessage(session) const list: TodoItem[] = [{ content: 'done', status: 'completed' }] session.append('todo/write', { todos: list }) - session.append('turn/end', { turn: 0, reason: { kind: 'completed' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect((await bench.tailProjections())?.values.todos).toEqual(list) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const cleared = await bench.tailProjections() expect(cleared?.values.todos).toBeNull() expect(cleared?.asOfSeq).toBe(session.seq - 1) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 2883cfdc02..154fde4857 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -22,7 +22,7 @@ const testToolSignal = new AbortController().signal /** A parent Agent backed by a real Session — the tool reads `agent.session`. */ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { - const session = new Session(SessionId(id)) + const session = Session.create(SessionId(id)) return { id: SessionId(id), session } as unknown as Agent & { session: Session } } diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index e72d20ed78..cf44d9aace 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/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/typert/README.md -README.md: d11fd8f57245379d67d2a1cdcca334f0032db469 -README.zh.md: 97e57f9585efa2e86edc1edf576fef4738b63203 +README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160 +README.zh.md: 0d4be016b766178b54f7e269583fa4200e3eb24b diff --git a/packages/typert/README.md b/packages/typert/README.md index d11fd8f572..ad9f843e48 100644 --- a/packages/typert/README.md +++ b/packages/typert/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -Typert separates source analysis, runtime storage, and Loader discovery into independent packages. +Typert separates source analysis, runtime storage, and Loader discovery. | Package | Role | Cordis key | |---|---|---| -| [`registry/`](registry/README.md) | Runtime package reflection and live Zod schema registry | `ctx.typert` | -| [`loader/`](loader/README.md) | Loader-entry discovery and generated host-artifact registration | consumes `ctx.loader`, `ctx.typert` | -| [`generator/`](generator/README.md) | Compiler-independent type analysis and artifact generation | build-time library | +| [`registry/`](registry/README.md) | Stores runtime package reflection and schemas | `ctx.typert` | +| [`loader/`](loader/README.md) | Discovers Loader entries and registers generated host artifacts | consumes `ctx.loader` and `ctx.typert` | +| [`generator/`](generator/README.md) | Generates runtime artifacts from source types | build-time library | diff --git a/packages/typert/README.zh.md b/packages/typert/README.zh.md index 97e57f9585..0d4be016b7 100644 --- a/packages/typert/README.zh.md +++ b/packages/typert/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -Typert 将源代码分析、运行时存储和 Loader 发现机制拆分为彼此独立的包(package)。 +Typert 将源代码分析、运行时存储和 Loader 发现机制分离。 | 包 | 职责 | Cordis 键 | |---|---|---| -| [`registry/`](registry/README.md) | 运行时包反射和实时 Zod schema 注册表 | `ctx.typert` | -| [`loader/`](loader/README.md) | 发现 Loader 条目并注册所生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | -| [`generator/`](generator/README.md) | 与编译器无关的类型分析和产物生成 | 构建时库 | +| [`registry/`](registry/README.md) | 存储运行时包反射和 schema | `ctx.typert` | +| [`loader/`](loader/README.md) | 发现 Loader 条目并注册生成的宿主产物 | 消费 `ctx.loader` 和 `ctx.typert` | +| [`generator/`](generator/README.md) | 从源代码类型生成运行时产物 | 构建时库 | diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index b098728811..cd6588c0ab 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/generator/README.md README.md: c343fd9475a9407159037f0a10e3a0586a77c3da -README.zh.md: e00abe205e5c5c33e7e0028606df169d447e4006 +README.zh.md: f9f863fe67d256b9744d7caeda0680f715808714 diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index e00abe205e..f9f863fe67 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -4,7 +4,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。 -宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包(package)所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 +宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 ## 分析模型 @@ -32,7 +32,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何 无。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 系统会跳过包导出中的模式匹配;参与贡献的包需要具体的导出目标。 - 跨 face 的具名重新导出和星号重新导出会生成链接;在 `TypeTargetModel` 能够不经展平便表示模块命名空间之前,命名空间重新导出会失败。 diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 90fb32e5af..3e9d8f7f61 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs b/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs new file mode 100644 index 0000000000..f70155b311 --- /dev/null +++ b/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs @@ -0,0 +1,310 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import { z } from 'zod' + +const Payload$schema = z.object({ + 'name': z.string(), + 'count': z.number().optional(), +}).describe('Runtime-validating data root.') + +export const Payload = Payload$schema + +export const TYPERT = { + package: '@fixture/host', + face: 'host', + schemas: [ + { name: 'Payload', schema: Payload }, + ], + model: { + "services": [ + { + "description": "Service exported only through a non-default alias.", + "summary": "Service exported only through a non-default alias.", + "tags": [], + "jsDoc": "/** Service exported only through a non-default alias. */", + "key": "aliased", + "exportName": "PublicAliasedService", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Service exported only through the package default.", + "summary": "Service exported only through the package default.", + "tags": [], + "jsDoc": "/** Service exported only through the package default. */", + "key": "defaultOnly", + "exportName": "default", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Fixture service with generic, mapped, and truly external boundary types.", + "summary": "Fixture service with generic, mapped, and truly external boundary types.", + "tags": [], + "jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */", + "key": "demo", + "exportName": "DemoService", + "members": [ + { + "kind": "method", + "name": "inspect", + "signature": "inspect(agent: Agent<{ ready: true }>, flags: Flags<Payload>): Present<Payload>", + "summary": "Inspect one agent without flattening its generic state.", + "jsDoc": "/** Inspect one agent without flattening its generic state. */" + }, + { + "kind": "method", + "name": "acceptsExternal", + "signature": "acceptsExternal(schema: ZodType<string>): void", + "summary": "Keep an npm-owned type as External.", + "jsDoc": "/** Keep an npm-owned type as External. */" + }, + { + "kind": "method", + "name": "setPhase", + "signature": "setPhase(phase: AgentPhase): void", + "summary": "Accept a developer-authored enum without flattening it.", + "jsDoc": "/** Accept a developer-authored enum without flattening it. */" + }, + { + "kind": "method", + "name": "inspectSyntax", + "signature": "inspectSyntax(zoo: SyntaxZoo): void", + "summary": "Exercise every retained type-graph shape from a public boundary.", + "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */" + }, + { + "kind": "method", + "name": "inspectAsync", + "signature": "async inspectAsync(zoo: SyntaxZoo): Promise<void>", + "summary": "Preserve async source metadata without changing its type signature.", + "jsDoc": "/** Preserve async source metadata without changing its type signature. */" + }, + { + "kind": "method", + "name": "destructure", + "signature": "destructure({ name }: Payload, [suffix]: [string]): string", + "summary": "Retain an authored binding-pattern parameter.", + "jsDoc": "/** Retain an authored binding-pattern parameter. */" + } + ], + "types": [ + { + "name": "AbstractEntity", + "declaration": "export abstract class AbstractEntity implements Entity {\n abstract readonly id: string;\n}" + }, + { + "name": "Added", + "declaration": "export type Added<Value> = { readonly [Key in keyof Value]?: Value[Key] };" + }, + { + "name": "Agent", + "declaration": "export class Agent<State extends object = { ready: boolean; }> implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run<Value>(input: Box<Value>): Promise<Present<Value>>;\n}" + }, + { + "name": "AgentPhase", + "declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}" + }, + { + "name": "Box", + "declaration": "export interface Box<T> {\n readonly value: T;\n}" + }, + { + "name": "Callable", + "declaration": "export interface Callable {\n (value: string): number;\n new (value: string): Entity;\n readonly [key: string]: unknown;\n}" + }, + { + "name": "Entity", + "declaration": "export interface Entity {\n readonly id: string;\n}" + }, + { + "name": "Flags", + "declaration": "export type Flags<T> = { readonly [K in keyof T]?: boolean };" + }, + { + "name": "Guards", + "declaration": "export interface Guards {\n isEntity(value: unknown): value is Entity;\n isFluent(): this is Guards;\n assertEntity(value: unknown): asserts value is Entity;\n assertPresent(value: unknown): asserts value;\n fluent(): this;\n}" + }, + { + "name": "Payload", + "declaration": "export interface Payload {\n name: string;\n count?: number;\n}" + }, + { + "name": "PlainMap", + "declaration": "export type PlainMap<Value> = { [Key in keyof Value]: Value[Key] };" + }, + { + "name": "Present", + "declaration": "export type Present<T> = T extends null | undefined ? never : T;" + }, + { + "name": "Recursive", + "declaration": "export interface Recursive extends Box<string> {\n readonly next?: Recursive;\n}" + }, + { + "name": "Remapped", + "declaration": "export type Remapped<Value> = { -readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key] };" + }, + { + "name": "Result", + "declaration": "export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never;" + }, + { + "name": "Route", + "declaration": "export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`;" + }, + { + "name": "StringResult", + "declaration": "export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never;" + }, + { + "name": "SyntaxZoo", + "declaration": "export interface SyntaxZoo {\n anyValue: any;\n bigintValue: bigint;\n parenthesized: (Entity | null);\n literals: 1 | 1n | -2 | -2n | false | `fixed`;\n readonly uniqueToken: unique symbol;\n intersection: Entity & { active: boolean; };\n array: string[];\n tuple: [head: string, count?: number, ...tail: boolean[]];\n unnamedTuple: [string?, ...number[]];\n readonlyTuple: readonly [string, number];\n object: { readonly value?: string; 'quoted-name': number; 1: boolean; ['computed']: symbol; invoke?(input: number): void; };\n callback: <Value extends Entity = Entity>(this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise<Value>;\n constCallback: <const Value extends readonly string[]>(value: Value) => Value;\n factory: new <Value extends Entity>(value: Value) => Value;\n abstractFactory: abstract new (id: string) => AbstractEntity;\n indexed: Payload['name'];\n inferred: Result<() => string>;\n constrainedInfer: StringResult<['value']>;\n topic: Topic<'ready'>;\n route: Route<'source', 'target'>;\n query: typeof phaseOrder;\n instantiatedQuery: typeof genericFactory<string>;\n imported: import('zod').ZodType<string>;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance<Entity, Payload, Box<string>>;\n plainMap: PlainMap<Payload>;\n remapped: Remapped<Payload>;\n added: Added<Payload>;\n abstractEntity: AbstractEntity;\n recursive: Recursive;\n tagOnly: TagOnly;\n unpunctuated: Unpunctuated;\n}" + }, + { + "name": "TagOnly", + "declaration": "export interface TagOnly {\n readonly value: string;\n}" + }, + { + "name": "Topic", + "declaration": "export type Topic<Name extends string> = `demo/${Name}`;" + }, + { + "name": "Unpunctuated", + "declaration": "export interface Unpunctuated {\n readonly value: string;\n}" + }, + { + "name": "Variance", + "declaration": "export interface Variance<in Input, out Output, in out State> {\n consume: (input: Input) => void;\n readonly produce: () => Output;\n state: State;\n}" + } + ] + } + ], + "events": [ + { + "tags": [], + "name": "demo/property", + "signature": "'demo/property'(payload: Payload): void" + }, + { + "description": "A generic fixture event.", + "summary": "A generic fixture event.", + "tags": [ + { + "name": "param", + "argument": "agent", + "comment": "- emitting agent.", + "text": "@param agent - emitting agent.\n *" + }, + { + "name": "param", + "argument": "payload", + "comment": "- event payload.", + "text": "@param payload - event payload.\n *" + }, + { + "name": "mode", + "comment": "emit", + "text": "@mode emit" + } + ], + "jsDoc": "/**\n * A generic fixture event.\n * @param agent - emitting agent.\n * @param payload - event payload.\n * @mode emit\n */", + "name": "demo/ready", + "mode": "emit", + "signature": "'demo/ready'(agent: Agent<{ ready: true; }>, payload: Box<Payload>): void" + }, + { + "tags": [ + { + "name": "mode", + "comment": "serial", + "text": "@mode serial" + } + ], + "jsDoc": "/** @mode serial */", + "name": "demo/serial-property", + "mode": "serial", + "signature": "'demo/serial-property'(payload: Payload): void" + }, + { + "tags": [], + "name": "demo/unmodeled", + "signature": "'demo/unmodeled'(): void" + } + ], + "objects": [ + { + "description": "Reference-passed capability object.", + "summary": "Reference-passed capability object.", + "tags": [ + { + "name": "typert", + "comment": "object", + "text": "@typert object" + } + ], + "jsDoc": "/**\n * Reference-passed capability object.\n * @typert object\n */", + "name": "Agent", + "exportName": "Agent", + "members": [ + { + "kind": "property", + "name": "id", + "signature": "readonly id: string" + }, + { + "kind": "property", + "name": "state", + "signature": "state: State" + }, + { + "kind": "getter", + "name": "label", + "signature": "get label(): string", + "summary": "Read the public display label.", + "jsDoc": "/** Read the public display label. */" + }, + { + "kind": "setter", + "name": "label", + "signature": "set label(value: string)", + "summary": "Accept a public display label.", + "jsDoc": "/** Accept a public display label. */" + }, + { + "kind": "method", + "name": "run", + "signature": "run<Value>(input: Box<Value>): Promise<Present<Value>>", + "summary": "Run one typed input.", + "jsDoc": "/** Run one typed input. */" + } + ], + "types": [ + { + "name": "Box", + "declaration": "export interface Box<T> {\n readonly value: T;\n}" + }, + { + "name": "Present", + "declaration": "export type Present<T> = T extends null | undefined ? never : T;" + } + ] + } + ] + }, +} diff --git a/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs b/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs new file mode 100644 index 0000000000..f70155b311 --- /dev/null +++ b/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs @@ -0,0 +1,310 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import { z } from 'zod' + +const Payload$schema = z.object({ + 'name': z.string(), + 'count': z.number().optional(), +}).describe('Runtime-validating data root.') + +export const Payload = Payload$schema + +export const TYPERT = { + package: '@fixture/host', + face: 'host', + schemas: [ + { name: 'Payload', schema: Payload }, + ], + model: { + "services": [ + { + "description": "Service exported only through a non-default alias.", + "summary": "Service exported only through a non-default alias.", + "tags": [], + "jsDoc": "/** Service exported only through a non-default alias. */", + "key": "aliased", + "exportName": "PublicAliasedService", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Service exported only through the package default.", + "summary": "Service exported only through the package default.", + "tags": [], + "jsDoc": "/** Service exported only through the package default. */", + "key": "defaultOnly", + "exportName": "default", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Fixture service with generic, mapped, and truly external boundary types.", + "summary": "Fixture service with generic, mapped, and truly external boundary types.", + "tags": [], + "jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */", + "key": "demo", + "exportName": "DemoService", + "members": [ + { + "kind": "method", + "name": "inspect", + "signature": "inspect(agent: Agent<{ ready: true }>, flags: Flags<Payload>): Present<Payload>", + "summary": "Inspect one agent without flattening its generic state.", + "jsDoc": "/** Inspect one agent without flattening its generic state. */" + }, + { + "kind": "method", + "name": "acceptsExternal", + "signature": "acceptsExternal(schema: ZodType<string>): void", + "summary": "Keep an npm-owned type as External.", + "jsDoc": "/** Keep an npm-owned type as External. */" + }, + { + "kind": "method", + "name": "setPhase", + "signature": "setPhase(phase: AgentPhase): void", + "summary": "Accept a developer-authored enum without flattening it.", + "jsDoc": "/** Accept a developer-authored enum without flattening it. */" + }, + { + "kind": "method", + "name": "inspectSyntax", + "signature": "inspectSyntax(zoo: SyntaxZoo): void", + "summary": "Exercise every retained type-graph shape from a public boundary.", + "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */" + }, + { + "kind": "method", + "name": "inspectAsync", + "signature": "async inspectAsync(zoo: SyntaxZoo): Promise<void>", + "summary": "Preserve async source metadata without changing its type signature.", + "jsDoc": "/** Preserve async source metadata without changing its type signature. */" + }, + { + "kind": "method", + "name": "destructure", + "signature": "destructure({ name }: Payload, [suffix]: [string]): string", + "summary": "Retain an authored binding-pattern parameter.", + "jsDoc": "/** Retain an authored binding-pattern parameter. */" + } + ], + "types": [ + { + "name": "AbstractEntity", + "declaration": "export abstract class AbstractEntity implements Entity {\n abstract readonly id: string;\n}" + }, + { + "name": "Added", + "declaration": "export type Added<Value> = { readonly [Key in keyof Value]?: Value[Key] };" + }, + { + "name": "Agent", + "declaration": "export class Agent<State extends object = { ready: boolean; }> implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run<Value>(input: Box<Value>): Promise<Present<Value>>;\n}" + }, + { + "name": "AgentPhase", + "declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}" + }, + { + "name": "Box", + "declaration": "export interface Box<T> {\n readonly value: T;\n}" + }, + { + "name": "Callable", + "declaration": "export interface Callable {\n (value: string): number;\n new (value: string): Entity;\n readonly [key: string]: unknown;\n}" + }, + { + "name": "Entity", + "declaration": "export interface Entity {\n readonly id: string;\n}" + }, + { + "name": "Flags", + "declaration": "export type Flags<T> = { readonly [K in keyof T]?: boolean };" + }, + { + "name": "Guards", + "declaration": "export interface Guards {\n isEntity(value: unknown): value is Entity;\n isFluent(): this is Guards;\n assertEntity(value: unknown): asserts value is Entity;\n assertPresent(value: unknown): asserts value;\n fluent(): this;\n}" + }, + { + "name": "Payload", + "declaration": "export interface Payload {\n name: string;\n count?: number;\n}" + }, + { + "name": "PlainMap", + "declaration": "export type PlainMap<Value> = { [Key in keyof Value]: Value[Key] };" + }, + { + "name": "Present", + "declaration": "export type Present<T> = T extends null | undefined ? never : T;" + }, + { + "name": "Recursive", + "declaration": "export interface Recursive extends Box<string> {\n readonly next?: Recursive;\n}" + }, + { + "name": "Remapped", + "declaration": "export type Remapped<Value> = { -readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key] };" + }, + { + "name": "Result", + "declaration": "export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never;" + }, + { + "name": "Route", + "declaration": "export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`;" + }, + { + "name": "StringResult", + "declaration": "export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never;" + }, + { + "name": "SyntaxZoo", + "declaration": "export interface SyntaxZoo {\n anyValue: any;\n bigintValue: bigint;\n parenthesized: (Entity | null);\n literals: 1 | 1n | -2 | -2n | false | `fixed`;\n readonly uniqueToken: unique symbol;\n intersection: Entity & { active: boolean; };\n array: string[];\n tuple: [head: string, count?: number, ...tail: boolean[]];\n unnamedTuple: [string?, ...number[]];\n readonlyTuple: readonly [string, number];\n object: { readonly value?: string; 'quoted-name': number; 1: boolean; ['computed']: symbol; invoke?(input: number): void; };\n callback: <Value extends Entity = Entity>(this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise<Value>;\n constCallback: <const Value extends readonly string[]>(value: Value) => Value;\n factory: new <Value extends Entity>(value: Value) => Value;\n abstractFactory: abstract new (id: string) => AbstractEntity;\n indexed: Payload['name'];\n inferred: Result<() => string>;\n constrainedInfer: StringResult<['value']>;\n topic: Topic<'ready'>;\n route: Route<'source', 'target'>;\n query: typeof phaseOrder;\n instantiatedQuery: typeof genericFactory<string>;\n imported: import('zod').ZodType<string>;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance<Entity, Payload, Box<string>>;\n plainMap: PlainMap<Payload>;\n remapped: Remapped<Payload>;\n added: Added<Payload>;\n abstractEntity: AbstractEntity;\n recursive: Recursive;\n tagOnly: TagOnly;\n unpunctuated: Unpunctuated;\n}" + }, + { + "name": "TagOnly", + "declaration": "export interface TagOnly {\n readonly value: string;\n}" + }, + { + "name": "Topic", + "declaration": "export type Topic<Name extends string> = `demo/${Name}`;" + }, + { + "name": "Unpunctuated", + "declaration": "export interface Unpunctuated {\n readonly value: string;\n}" + }, + { + "name": "Variance", + "declaration": "export interface Variance<in Input, out Output, in out State> {\n consume: (input: Input) => void;\n readonly produce: () => Output;\n state: State;\n}" + } + ] + } + ], + "events": [ + { + "tags": [], + "name": "demo/property", + "signature": "'demo/property'(payload: Payload): void" + }, + { + "description": "A generic fixture event.", + "summary": "A generic fixture event.", + "tags": [ + { + "name": "param", + "argument": "agent", + "comment": "- emitting agent.", + "text": "@param agent - emitting agent.\n *" + }, + { + "name": "param", + "argument": "payload", + "comment": "- event payload.", + "text": "@param payload - event payload.\n *" + }, + { + "name": "mode", + "comment": "emit", + "text": "@mode emit" + } + ], + "jsDoc": "/**\n * A generic fixture event.\n * @param agent - emitting agent.\n * @param payload - event payload.\n * @mode emit\n */", + "name": "demo/ready", + "mode": "emit", + "signature": "'demo/ready'(agent: Agent<{ ready: true; }>, payload: Box<Payload>): void" + }, + { + "tags": [ + { + "name": "mode", + "comment": "serial", + "text": "@mode serial" + } + ], + "jsDoc": "/** @mode serial */", + "name": "demo/serial-property", + "mode": "serial", + "signature": "'demo/serial-property'(payload: Payload): void" + }, + { + "tags": [], + "name": "demo/unmodeled", + "signature": "'demo/unmodeled'(): void" + } + ], + "objects": [ + { + "description": "Reference-passed capability object.", + "summary": "Reference-passed capability object.", + "tags": [ + { + "name": "typert", + "comment": "object", + "text": "@typert object" + } + ], + "jsDoc": "/**\n * Reference-passed capability object.\n * @typert object\n */", + "name": "Agent", + "exportName": "Agent", + "members": [ + { + "kind": "property", + "name": "id", + "signature": "readonly id: string" + }, + { + "kind": "property", + "name": "state", + "signature": "state: State" + }, + { + "kind": "getter", + "name": "label", + "signature": "get label(): string", + "summary": "Read the public display label.", + "jsDoc": "/** Read the public display label. */" + }, + { + "kind": "setter", + "name": "label", + "signature": "set label(value: string)", + "summary": "Accept a public display label.", + "jsDoc": "/** Accept a public display label. */" + }, + { + "kind": "method", + "name": "run", + "signature": "run<Value>(input: Box<Value>): Promise<Present<Value>>", + "summary": "Run one typed input.", + "jsDoc": "/** Run one typed input. */" + } + ], + "types": [ + { + "name": "Box", + "declaration": "export interface Box<T> {\n readonly value: T;\n}" + }, + { + "name": "Present", + "declaration": "export type Present<T> = T extends null | undefined ? never : T;" + } + ] + } + ] + }, +} diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index 892e6515eb..b9b6275c20 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/loader/README.md README.md: ab9293de1630fdbe8c560bb9e6d00c272cc34161 -README.zh.md: 7ececd07ac9a12bc04dca8206e348c25adc4ee76 +README.zh.md: f73beec4fb09d5376d905a5e22e8bb1ccb710f90 diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index 7ececd07ac..f73beec4fb 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -4,7 +4,7 @@ 生成的 Typert 产物所用的 Loader 集成,仅支持 Node。该插件需要 `ctx.loader` 和 `ctx.typert`;它本身不提供注册表。 -激活时,该插件会扫描现有的 Loader 配置项。随后它会监听 Cordis `internal/plugin` 生命周期通知,解析每个配置项所属包(package)的 `package.json`,在其导出 `./typert` 时导入该子路径,校验其 `TYPERT` manifest(元数据清单),并注册该贡献项,直到配置项或本插件卸载。如果导入操作在配置项或本插件卸载后才结束,系统会丢弃其结果。 +激活时,该插件会扫描现有的 Loader 配置项。随后它会监听 Cordis `internal/plugin` 生命周期通知,解析每个配置项所属包的 `package.json`,在其导出 `./typert` 时导入该子路径,校验其 `TYPERT` manifest(元数据清单),并注册该贡献项,直到配置项或本插件卸载。如果导入操作在配置项或本插件卸载后才结束,系统会丢弃其结果。 `packages` 用于列出需要为嵌套在另一 Loader 配置项下的插件额外注册的包产物。Cordis fiber 不会保留这些嵌套插件的 npm 包说明符,因此这里通过显式配置划定边界;配置中列出的每个包都必须能从配置树解析,并导出 `./typert`。 @@ -18,7 +18,7 @@ 无直接影响。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 发现机制只会导入宿主侧产物;若要为客户端运行时添加等价的发现机制,需要先有独立的组合所有者。 - Loader 配置项会自动发现。嵌套插件或非 Loader 插件需要显式加入 `packages`,或由组合所有者直接负责调用 `ctx.typert.register()`。 diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 5826b96b5a..ca6db771e3 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index a9a449649f..b8c97637c9 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: 6ef8b22805e21a379b4c2fac4bf9fbae447c41a1 +README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 6ef8b22805..db2140e51d 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -生成的 Typert 产物所用的运行时注册表。每个注册项包含某个包(package)在一个 face 上的业务反射信息,以及可选的运行时 Zod schema;`ctx.typert` 会以原子方式同时注册两者,并在发起调用的 Cordis fiber 释放时一并移除它们。TypeScript 分析和代码生成由 [`dsh-typert-generator`](../generator/README.md) 负责。 +生成的 Typert 产物所用的运行时注册表。每个注册项包含某个包在一个 face 上的业务反射信息,以及可选的运行时 Zod schema;`ctx.typert` 会以原子方式同时注册两者,并在发起调用的 Cordis fiber 释放时一并移除它们。TypeScript 分析和代码生成由 [`dsh-typert-generator`](../generator/README.md) 负责。 包反射信息以 `<package>#<face>` 为键。schema 以 `<package>#<name>` 为键,并保留生成方的 Zod 实例。系统按需在消费方边界计算 JSON Schema。 @@ -25,7 +25,7 @@ 无直接影响。将反射信息放入请求的消费方负责由此产生的前缀变化。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 注册表存储生成的反射信息,但不会合并宿主侧与客户端侧的图,也不会解析 TypeScript 引用;这些由分析器和产物输出器负责。 - schema 键不包含 face,因为宿主侧和客户端侧在不同的上下文中运行。若在同一上下文中注册来自两个 face 的同名 schema,系统会将其作为重复项拒绝。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 986ac55a37..b543589dc6 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 541de5b199..fba1f95c52 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/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/ui/README.md -README.md: 76ca80e5685f70e73a6c46fe8d980f951b965ed3 -README.zh.md: 3958b0bdfb5d8cbab82f9fecfe54d12d462738ea +README.md: 15754410a4a81eb3fc898dd55269ddd1637e1dab +README.zh.md: 4023b80085998f57ed321bfda3a0abdd08b70a28 diff --git a/packages/ui/README.md b/packages/ui/README.md index 76ca80e568..15754410a4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -6,16 +6,12 @@ Human-facing channels and the out-of-process SDK server. These are **product** p | Package | Role | ctx key | |---|---|---| -| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` | -| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | -| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | -| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | -| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | -| `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) | +| [`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/README.md) | Presents and persists user-facing permission presets. | `ctx.permission` | +| [`user-interaction/`](user-interaction/README.md) | Defines the provider-neutral human question/answer seam. | `ctx.userInteraction` | +| [`tool-ask-user/`](tool-ask-user/README.md) | Exposes human questions to the model. | (registers on `ctx.tools`) | +| [`jsonrpc/`](jsonrpc/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC. | (drives `ctx.agents`) | +| [`app-boot/`](app-boot/README.md) | Provides shared boot support for application launchers. | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. [`jsonrpc`](jsonrpc/README.md) serves out-of-process SDK clients, while non-interactive one-shot tasks use `cli-demo`. [`commands`](commands/README.md) is the human-only discovery and dispatch plane for interactive adapters; command input and output do not become model messages. - -`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers. - -The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`), each with its own entry contract. The product [`dsh`](../../apps/cli/README.md) CLI uses no demo bundle. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional 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. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index 3958b0bdfb..4023b80085 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -2,20 +2,16 @@ [English](README.md) | 中文 -面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包(package):由用户或 SDK 客户端直接操作的真实接口。 +面向用户的通道和进程外 SDK 服务器。这些是**产品**包:由用户或 SDK 客户端直接操作的真实接口。 | 包 | 职责 | ctx 键 | |---|---|---| -| `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | -| `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | -| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` | -| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | -| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | -| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| [`commands/`](commands/README.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` | +| [`user-approval/`](user-approval/README.md) | 协调一次性审批决策。 | `ctx.approval` | +| [`permission/`](permission/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permission` | +| [`user-interaction/`](user-interaction/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userInteraction` | +| [`tool-ask-user/`](tool-ask-user/README.md) | 向模型公开用户问题。 | (注册到 `ctx.tools`) | +| [`jsonrpc/`](jsonrpc/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务。 | (驱动 `ctx.agents`) | +| [`app-boot/`](app-boot/README.md) | 为应用启动器提供共享启动支持。 | (供各 bin 使用的库) | -UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是面向交互式适配器的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。 - -`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 - -基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`),各自拥有入口契约。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)不使用 demo bundle。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +这些包通过现有的 agent(智能体)和会话契约集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 1577917eb8..09d621691f 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/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/ui/app-boot/README.md -README.md: dfb8b45b1c2ea06683b44242f77637db9a1b783c -README.zh.md: db69e7609b7a5458b862a26a70ea21651fc519b9 +README.md: fbdd4c1332a1cc52f15a8ce28264ea16d47fc552 +README.zh.md: b67fb126ea477acf2e79f5bc1d695a5fc9ca8c82 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index dfb8b45b1c..fbdd4c1332 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`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 the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. +Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`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. | Export | 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) | -| `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 (for tests) | +| `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 | @@ -25,7 +25,7 @@ Loader settlement rejects import and lifecycle failures with the failing entry a 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. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. 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. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. 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. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. 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. @@ -38,8 +38,6 @@ A developer's machine-local preferences live outside every repository in the Har Web keeps `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches 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. -Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. - ## 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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index db69e7609b..b67fb126ea 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -供 app bin([`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障处理知识只需维护一处并接受逐文件覆盖率门禁,不会在已发布产物之间逐渐分化。 +供 app bin([`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。 | 导出 | 职责 | |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 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 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | @@ -25,7 +25,7 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配 Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 @@ -38,8 +38,6 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 -子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 - ## 模型体验 模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 18a42a27a1..c1214953a5 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 7f3579cda1..2e5a133f00 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -340,10 +340,10 @@ export async function watchPersonalPatches( try { return await register } catch (error) { - // A surface can dispose the whole tree while the watcher is still opening - // (a TUI `/exit` typed during startup): the HMR effect registration then - // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, - // not a watch failure — return a no-op disposer instead of crashing. + // A surface can dispose the whole tree while the watcher is still opening; + // the HMR effect registration then fails with INACTIVE_EFFECT. That is the + // app exiting exactly as asked, not a watch failure, so return a no-op + // disposer instead of crashing. if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} throw error } @@ -625,11 +625,10 @@ export async function boot( stage = 'plugin tree failed to load' await mountRootInclude(ctx, absoluteConfigPath, patches) // A surface can finish and dispose the whole tree while startup is still - // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` past this point would throw a TypeError over - // an app that exited exactly as asked. Transactional group updates settle + // in flight, before the last entry settles. The Loader service goes with + // it, and the activation audit describes a live tree — reading `ctx.loader` + // past this point would throw a TypeError over an app that exited exactly + // as asked. Transactional group updates settle // lifecycle inside the mount, so the teardown can land before it returns; // re-check after every await. await ctx.get('loader')?.await() diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 96cad31ea3..24a24b4c58 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -435,11 +435,10 @@ describe('boot', () => { }) it('returns instead of asserting over a tree a surface disposed mid-startup', async () => { - // What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root - // fiber, which lands while boot() is still awaiting the Loader whenever the - // surface renders before the last entry settles. The Loader service goes - // with the tree, so reading it for the post-boot assertions would crash an - // app that exited exactly as the user asked. + // A surface can dispose the root fiber while boot() is still awaiting the + // Loader, before the last entry settles. The Loader service goes with the + // tree, so reading it for the post-boot assertions would crash an app that + // exited exactly as the user asked. const dir = tmp() writeFileSync(join(dir, 'exiting.mjs'), [ 'export const name = "exiting"', diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index d9f4ffa830..1e88954f69 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -344,9 +344,8 @@ describe('include patches layered over one base', () => { // The surface/`--config`/personal composition: `dsh` includes one shared // base and applies each source as its own patch list at the SAME include // level, because patches never cross an include boundary. A later layer - // must therefore be able to reach a row an earlier layer inserted — - // otherwise every surface-only row (the whole TUI front door) would be - // invisible to the user's `~/.dsh/config.yaml`. + // must therefore be able to reach a row an earlier layer inserted, or + // surface-only rows would be invisible to the user's personal config. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 53df1d84b7..7c92d53e56 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -235,11 +235,11 @@ describe('boot with personal patches', () => { }) it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { - // A TUI `/exit` typed during startup disposes the whole tree while - // registerConfig's effect registration is still in flight (the HMR effect - // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, - // so the watcher must not crash the process. The stub makes the race - // deterministic — the live-teardown ordering itself is not stageable. + // A surface can dispose the whole tree while registerConfig's effect + // registration is still in flight (the HMR effect then fails with + // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher + // must not crash the process. The stub makes the race deterministic — the + // live-teardown ordering itself is not stageable. const dir = tmp() const ctx = await boot(NAME, writeTree(dir)) try { diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 400fef35bf..83cb7e4883 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/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/ui/commands/README.md -README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391 -README.zh.md: 9cc6a3f31e55da5d56c5b49ba78fbd66381ed680 +README.md: a931628df28cb11ffb8e91068f7602a4a30cff94 +README.zh.md: e1ef7288ca7dca2945ec93d0ba23a3d1d6edf90c diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 4ad72cf9e2..a931628df2 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -16,7 +16,7 @@ Handlers return `success` or `error` plus optional UI text. Results are rendered ## Composition -The terminal app bundle mounts this service with `dsh-tui`; the UI-less agent spine and ACP automation app do not. Custom interactive compositions and command producers mount `@deepseek-ai/dsh-commands` explicitly. +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. ## Model Experience diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index 9cc6a3f31e..e1ef7288ca 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。 +由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。 ## 服务契约 @@ -16,7 +16,7 @@ ## 组合 -终端应用组合包会将此服务与 `dsh-tui` 一起挂载;无 UI 的 agent 主干和 ACP(Agent Client Protocol)自动化应用不会挂载它。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。 +随产品交付的 `dsh` 基础组合会挂载此服务,Web 客户端通过它分派命令。无 UI 的演示主干和 ACP(Agent Client Protocol)自动化不提供命令适配器。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。 ## 模型体验 diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json index e22dee8f3b..7d35603a79 100644 --- a/packages/ui/commands/package.json +++ b/packages/ui/commands/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 114f0a47df..c910abf7d4 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -396,7 +396,7 @@ describe('CommandService', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('mid')) - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', new AbortController().signal) expect(agent.session.events.map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index 8942c29a92..d81f54f405 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/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/ui/jsonrpc/README.md -README.md: ac47af28e69e647ba44a7718478db163d406f5dc -README.zh.md: 2ab600dce52f471d8eef63848e6283217008dcf6 +README.md: 9cd4876b52f9527745b27041eb2555408c73fabd +README.zh.md: a2b979da448c04c0578382889be2ae3ea6076166 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index ac47af28e6..9cd4876b52 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -18,11 +18,11 @@ Stdout carries only JSON-RPC frames. The deployment must not compose a stdout lo ## Shutdown and exit semantics -The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process. +The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process. ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. ## Model Experience @@ -42,6 +42,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown, and one accepted prompt runs to agent idle before that session accepts another. +- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown. +- **There is no per-prompt result** — `MessageId` identifies inbox admission only; clients that own an automation interval must define and observe that interval themselves. - **stdout purity is deployment-enforced** — a surrounding config can still load a stdout logger and corrupt the JSON-RPC channel; this plugin does not inspect or veto sibling loggers. - **Automatic adapter mounting is DeepSeek-specific** — `initialize` can reuse any pre-registered model adapter, but its only fallback mounts `dsh-llm-deepseek`. diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 2ab600dce5..a2b979da44 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`。对于需要区分「因 token 上限而结束但可接受的 agent 结果」与「基础设施故障」的评测宿主,请将其设为 `true`。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -18,11 +18,11 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 关闭与退出语义 -插件响应 `shutdown`,将 SDK 持有的 agent 和订阅 dispose(资源释放)至完全停稳,关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。 +插件响应 `shutdown`,刷新响应并 dispose(资源释放)根上下文,使 SDK 持有的 agent、订阅和持久化全部停稳,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者也会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样接收为该 SDK 会话中的一条用户消息。此包(package)不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样接收为该 SDK 会话中的一条用户消息。此包不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。 #### Token 影响 @@ -38,10 +38,11 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 -- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。 +- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭。 +- **没有逐提示词结果**:`MessageId` 只标识 inbox 准入;拥有自动化活动区间的客户端必须自行定义并观察该区间。 - **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。 - **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。 diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index ff51571ace..573f724ae0 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 78ac9ef2be..423f338877 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -2,7 +2,7 @@ * SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides * whether to load it; see the single-executable Agent Note and package README. * Stdout is reserved for protocol frames, so the tree must not load a stdout logger. - * This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin + * This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin * owns EOF and signal exits. Keep named plugin exports with no default export so * Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`. * @@ -40,14 +40,15 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({ /** * Serve SDK requests over the configured streams. Effect disposal shuts down * SDK-created agents and closes the transport. A `shutdown` response is flushed - * before this plugin's fiber is disposed and the process exits 0; the app bin + * before the root runtime is disposed and the process exits 0; the app bin * owns root-context disposal for EOF and signals. */ export function apply(ctx: Context, config: JsonRpcConfig): void { // Cordis applies the schema default before invoking the plugin. const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean } - // The later transport callback must dispose this plugin's fiber, not its ambient context. - const fiber = ctx.fiber + // Protocol shutdown owns the complete runtime process, so it must await the + // root lifecycle (including persistence) before exiting. + const rootFiber = ctx.root.fiber /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ const input = config.input ?? process.stdin /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ @@ -60,12 +61,13 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, }) - // Share one exit task and attempt flush and disposal independently before exiting. + // Share one exit task so racing shutdown requests cannot dispose the root or + // exit the process more than once. let exitTask: Promise<void> | undefined const disposeAndExit = (): Promise<void> => { exitTask ??= (async () => { await Promise.allSettled([Promise.resolve().then(() => transport.flush())]) - await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())]) + await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())]) exit(0) })() return exitTask diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index d9144aada4..e44b171c37 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -10,7 +10,7 @@ import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' -import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -19,7 +19,6 @@ import type { InitializeResult, JsonRpcTransportPeer, SessionEventNotification, - SessionFinishedNotification, SessionPromptParams, SessionPromptResult, SubagentFinishedNotification, @@ -28,8 +27,6 @@ import type { interface SessionRecord { handle: AgentHandle - lastTurnEnd: TurnEndReason | undefined - activePrompt: boolean } /** Recover the delegating parent from the service-owned scoped carrier. */ @@ -72,15 +69,12 @@ export class HarnessSdkServer { ) { const serverOptions = this.options this.disposers.push(ctx.on('session/event', (session, event) => { - if (event.type === 'turn/end') { - const rec = this.sessions.get(String(session.id)) - if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) { - rec.lastTurnEnd = event.data.reason - } - } const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) + this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) + })) this.disposers.push(ctx.on('session/created', (session) => { const parentSession = session.header.parentSession if (parentSession === undefined) return @@ -131,34 +125,21 @@ export class HarnessSdkServer { } /** - * Run one prompt to settlement; overlap on the same session fails. + * Queue one identified prompt without assigning later activity to it. * @param params - target session and user content. - * @returns acceptance after the turn settled. + * @returns the durable message identity. */ async prompt(params: SessionPromptParams): Promise<SessionPromptResult> { const rec = await this.getOrCreateSession(params.sessionId) - if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`) // An agent-loop-only reload disposes the loop's agents while this record // survives; a retained agent accepts followup() silently, so validate the // record against the live registry before delivery (as the ACP bridge does). if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) { throw new Error(`session agent was disposed outside the server: ${params.sessionId}`) } - rec.activePrompt = true - try { - rec.lastTurnEnd = undefined - rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })) - await rec.handle.agent.whenIdle() - const payload: SessionFinishedNotification = { - sessionId: params.sessionId, - status: this.finishedStatus(rec.lastTurnEnd), - reason: rec.lastTurnEnd, - } - this.transport.notify('session.finished', payload) - return { accepted: true } - } finally { - rec.activePrompt = false - } + const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }) + rec.handle.agent.followup(message) + return { messageId: message.id } } /** @@ -244,16 +225,11 @@ export class HarnessSdkServer { ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, }) - const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false } + const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) return rec } - private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { - if (!reason) return 'error' - return successStatus(reason.kind, this.options) - } - private hasAdapterFor(provider: string): boolean { return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false } diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts index 185805d739..af64092933 100644 --- a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -20,6 +20,7 @@ import * as jsonrpc from '../src/index.ts' type WireEvent = | { kind: 'frame'; frame: Record<string, unknown> } | { kind: 'write-complete'; ids: (string | number)[] } + | { kind: 'root-disposed' } | { kind: 'exit'; code: number } interface ApplyHarness { @@ -99,6 +100,7 @@ async function mountPlugin( output.on('error', (error: Error) => { outputErrors.push(error) }) const exit = (code: number): void => { events.push({ kind: 'exit', code }) } + ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness') const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) const frames = (): Record<string, unknown>[] => @@ -185,7 +187,12 @@ describe('dsh-jsonrpc plugin apply', () => { params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] }, }) const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response') - expect(response.result).toEqual({ accepted: true }) + expect((response.result as { messageId?: unknown }).messageId).toBeTypeOf('string') + await harness.waitForFrame( + frame => frame.method === 'session.status' + && (frame.params as { status?: string } | undefined)?.status === 'idle', + 'idle session status', + ) expect(llmServer.requests).toHaveLength(1) const body = llmServer.requests[0] as { model: string; messages: { role: string }[] } @@ -195,9 +202,9 @@ describe('dsh-jsonrpc plugin apply', () => { // Notifications use the same transport and arrive as id-less frames. const notifications = harness.frames().filter(frame => frame.id === undefined) expect(notifications.some(frame => frame.method === 'session.event')).toBe(true) - expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({ + expect(notifications.findLast(frame => frame.method === 'session.status')).toMatchObject({ jsonrpc: '2.0', - params: { sessionId: 'main', status: 'ok' }, + params: { sessionId: 'main', status: 'idle' }, }) } finally { await harness.dispose() @@ -224,16 +231,19 @@ describe('dsh-jsonrpc plugin apply', () => { const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1')) const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2')) const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0) + const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed') expect(firstResponse).toBeGreaterThanOrEqual(0) expect(secondResponse).toBeGreaterThanOrEqual(0) expect(firstComplete).toBeGreaterThan(firstResponse) expect(secondComplete).toBeGreaterThan(secondResponse) expect(flushComplete).toBeGreaterThan(firstComplete) expect(flushComplete).toBeGreaterThan(secondComplete) - expect(exitIndex).toBeGreaterThan(flushComplete) + expect(rootDisposed).toBeGreaterThan(flushComplete) + expect(exitIndex).toBeGreaterThan(rootDisposed) await settle() expect(harness.exits()).toEqual([0]) + expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1) const before = harness.frames().length harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } }) @@ -254,6 +264,7 @@ describe('dsh-jsonrpc plugin apply', () => { await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure') await settle() expect(harness.exits()).toEqual([0]) + expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1) expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed']) const before = harness.frames().length @@ -279,6 +290,7 @@ describe('dsh-jsonrpc plugin apply', () => { }) await harness.fiber.dispose() + expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false) const before = harness.frames().length harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } }) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 10931f1bd7..78f3e11983 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -127,12 +127,13 @@ describe('HarnessSdkServer', () => { }) as { serverInfo: { name: string } } expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime') - await server.handleRequest('session/prompt', { + const receipt = await server.handleRequest('session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }], }) + expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string') - expect(llmServer.requests).toHaveLength(1) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number } expect(body.model).toBe('dsagent-model') expect(body.max_tokens).toBe(321) @@ -140,16 +141,18 @@ describe('HarnessSdkServer', () => { expect(body.messages.at(-1)?.role).toBe('user') expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key') expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true) - expect(transport.notifications.at(-1)).toMatchObject({ - method: 'session.finished', - params: { sessionId: 'main', status: 'ok' }, + await vi.waitFor(() => { + expect(transport.notifications.findLast(n => n.method === 'session.status')).toEqual({ + method: 'session.status', + params: { sessionId: 'main', status: 'idle' }, + }) }) await server.handleRequest('session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'again' }], }) - expect(llmServer.requests).toHaveLength(2) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) const orphanHandle = await ctx.agents.create({ sessionId: SessionId('orphan-session'), @@ -168,24 +171,17 @@ describe('HarnessSdkServer', () => { } }) - it('rejects overlapping prompts for one session without serializing other sessions', async () => { - let releaseMain: (() => void) | undefined - const firstMainIdle = new Promise<void>((resolve) => { releaseMain = resolve }) - const mainWhenIdle = vi.fn<() => Promise<void>>() - .mockReturnValueOnce(firstMainIdle) - .mockResolvedValue(undefined) + it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn<Agent['followup']>() const mainAgent = ({ id: SessionId('main'), followup: mainFollowup, - whenIdle: mainWhenIdle, - } satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent + } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent const otherFollowup = vi.fn<Agent['followup']>() const otherAgent = ({ id: SessionId('other'), followup: otherFollowup, - whenIdle: vi.fn(() => Promise.resolve()), - } satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent + } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } const create = vi.fn(async (options: { sessionId: SessionId }) => @@ -202,20 +198,11 @@ describe('HarnessSdkServer', () => { contentBlocks: [{ type: 'text', text }], }) - const first = prompt('main', 'first') - await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() }) + expect((await prompt('main', 'first')).messageId).toBeTypeOf('string') + expect((await prompt('main', 'overlap')).messageId).toBeTypeOf('string') + expect((await prompt('other', 'independent')).messageId).toBeTypeOf('string') - await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') - await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) - releaseMain?.() - await expect(first).resolves.toEqual({ accepted: true }) - await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true }) - - mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed')) - await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') - await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) - - expect(mainFollowup).toHaveBeenCalledTimes(4) + expect(mainFollowup).toHaveBeenCalledTimes(2) expect(otherFollowup).toHaveBeenCalledOnce() await server.shutdown() expect(mainHandle.dispose).toHaveBeenCalledOnce() @@ -247,7 +234,7 @@ describe('HarnessSdkServer', () => { contentBlocks: [{ type: 'text', text }], }) - await expect(prompt('while live')).resolves.toEqual({ accepted: true }) + expect((await prompt('while live')).messageId).toBeTypeOf('string') live = false await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie') // The detached agent was never driven by the rejected prompt. @@ -255,61 +242,26 @@ describe('HarnessSdkServer', () => { await server.shutdown() }) - it('reports the message-turn outcome when a later non-message turn settles before idle', async () => { + it('forwards whole-agent status without attributing a turn outcome', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) const transport = new FakeTransport() - const server = new HarnessSdkServer(ctx, transport) as unknown as { - prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown> - sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }> - shutdown(): Promise<Record<string, never>> - } + const server = new HarnessSdkServer(ctx, transport) const session = ctx.sessions.create(SessionId('message-outcome')) const agent = ({ id: SessionId('message-outcome'), session, - followup(input: UserMessage) { - session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: input.source }, - }) - session.append('user/message', input, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) - session.append('turn/start', { - turn: 2, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'late metadata' }], - source: { kind: 'plugin', plugin: 'late-metadata' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - return input.id - }, - whenIdle: () => Promise.resolve(), - } satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent - ctx.agents.register(agent) - server.sessions.set('message-outcome', { - handle: { agent, dispose: () => Promise.resolve() }, - lastTurnEnd: undefined, - activePrompt: false, - }) + } satisfies Pick<Agent, 'id' | 'session'>) as Agent - await server.prompt({ - sessionId: 'message-outcome', - contentBlocks: [{ type: 'text', text: 'bounded prompt' }], - }) + ctx.emit('agent/status', agent, 'running') + ctx.emit('agent/status', agent, 'idle') - expect(transport.notifications.findLast(notification => notification.method === 'session.finished')) - .toEqual({ - method: 'session.finished', - params: { - sessionId: 'message-outcome', - status: 'error', - reason: { kind: 'max-tokens' }, - }, - }) + expect(transport.notifications.filter(notification => notification.method === 'session.status')) + .toEqual([ + { method: 'session.status', params: { sessionId: 'message-outcome', status: 'running' } }, + { method: 'session.status', params: { sessionId: 'message-outcome', status: 'idle' } }, + ]) await server.shutdown() await ctx.fiber.dispose() }) @@ -358,7 +310,7 @@ describe('HarnessSdkServer', () => { contentBlocks: [{ type: 'text', text: 'hello' }], }) - expect(llmServer.requests).toHaveLength(1) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) await server.shutdown() } finally { await ctx.fiber.dispose() @@ -883,43 +835,6 @@ describe('HarnessSdkServer', () => { }, ) - it('classifies defensive finish states', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-')) - const ctx = await makeHarness(storageDir) - try { - const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { - finishedStatus(reason: unknown): 'ok' | 'error' - shutdown(): Promise<Record<string, never>> - } - - expect(server.finishedStatus(undefined)).toBe('error') - expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error') - expect(server.finishedStatus({ kind: 'error' })).toBe('error') - await server.shutdown() - } finally { - await ctx.fiber.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - - it('can report max-token turn termination as an accepted evaluation result', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-')) - const ctx = await makeHarness(storageDir) - try { - const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as { - finishedStatus(reason: unknown): 'ok' | 'error' - shutdown(): Promise<Record<string, never>> - } - - expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok') - expect(server.finishedStatus({ kind: 'error' })).toBe('error') - await server.shutdown() - } finally { - await ctx.fiber.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - it('reports no adapter when the LLM service is absent', async () => { const ctx = new Context() try { @@ -1047,6 +962,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(3) + expect(on).toHaveBeenCalledTimes(4) }) }) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index 3e7d9db68b..cb48934488 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/permission/README.md README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a -README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184 +README.zh.md: d45f89e243ce2d8f6bb08943fb7e776ced106b5a diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 79d0ce9c09..d45f89e243 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -2,27 +2,27 @@ [English](README.md) | 中文 -通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 +通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 -两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 ## 模型体验 -间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 +间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 #### KV Cache 影响 不会直接使缓存失效;具名消费方拥有所有请求前缀变更。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 -- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 -- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 -- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 +- **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 +- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 +- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。 - **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c3554e3c4d..0ae48d6e2f 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d7f0dcc391..44d62a9c33 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -270,7 +270,7 @@ export class PermissionService extends Service { if (!this.names.includes(name)) { return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } } - this.set(agent.session, name) + this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) }) return { kind: 'success', text: `preset ${name}` } }, }) @@ -373,6 +373,11 @@ export class PermissionService extends Service { * @param name - the preset to switch to; unknown names throw. */ set(session: Session, name: string): void { + this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) }) + } + + /** Apply one preset with the caller-selected live or initialization policy writer. */ + private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void { const spec = this.resolve(name) if (this.current(session.events) !== name) { session.append('permission/preset', { preset: name }) @@ -382,7 +387,7 @@ export class PermissionService extends Service { setSandboxMode(session, spec.sandbox) } if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { - setApprovalPolicy(session, spec.approval) + setApproval(spec.approval) } } diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 0d80b4533b..4940e3b155 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -44,7 +44,7 @@ async function mounted(options: { } function freshSession(id: string): Session { - return new Session(SessionId(id)) + return Session.create(SessionId(id)) } async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> { @@ -220,7 +220,7 @@ describe('new-session default', () => { defaultPreset: 'danger-full-access', }) const legacy = freshSession('legacy-source') - legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) expect(ctx.permission.current(resumed.events)).toBe('workspace-write') diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index a50c17a399..3a0e2091c9 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -9,7 +9,7 @@ * service removes the key (HMR safety). */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -19,6 +19,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import CommandService from '@deepseek-ai/dsh-commands' import PermissionService from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' +import ApprovalService from '@deepseek-ai/dsh-user-approval' async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() @@ -31,16 +32,17 @@ async function harness(options: { withPermission?: boolean; config?: Config } = run() { throw new Error('permission tests do not execute bash') }, start() { throw new Error('permission tests do not execute bash') }, }) - ctx.provide('approval', { config: { policy: 'ask' } }) + await ctx.plugin(ApprovalService) if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {}) return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) } } /** Mint a scoped agent over a live session (the command executor's addressing shape). */ -async function agentFor(ctx: Context, session: Session): Promise<Agent> { - const agent = { id: session.id, session } as Agent +async function agentFor(ctx: Context, session: Session) { + const inject = vi.fn<Agent['inject']>() + const agent = { id: session.id, session, inject } as unknown as Agent await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] })) - return agent + return { agent, inject } } describe('permissions projection unit', () => { @@ -62,7 +64,7 @@ describe('permissions projection unit', () => { expect(changes).toHaveLength(3) expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) // Unrelated event: same-reference apply, no notification. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(changes).toHaveLength(3) }) @@ -87,17 +89,23 @@ describe('permissions projection unit', () => { describe('/permission command', () => { it('switches through permission.set and logs the lifecycle pair', async () => { const { ctx, session } = await harness() - const agent = await agentFor(ctx, session) + const { agent, inject } = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(inject.mock.calls[0]?.[0]).toMatchObject({ + content: [{ + type: 'text', + text: 'The approval policy changed from "ask" to "never" (changed by the user).', + }], + }) const run = session.events.find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) it('reports the current preset and the table on bare invocation', async () => { const { ctx, session } = await harness() - const agent = await agentFor(ctx, session) + const { agent } = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', @@ -108,7 +116,7 @@ describe('/permission command', () => { it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() - const agent = await agentFor(ctx, session) + const { agent } = await agentFor(ctx, session) const before = session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 85beed8474..8fedcdab4a 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f -README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5 +README.zh.md: fdfa1ff2470258e2864f505fbadfcd2fc8be8101 diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index 48a5b5d0d1..fdfa1ff247 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,11 +15,11 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 -此包(package)是用户交互 seam 的消费方。它不渲染 UI,也不了解输入的收集方式;它只将模型参数转换为 `AskUserQuestionRequest`,并把用户回答返回给 agent loop(智能体循环)。 +此包是用户交互 seam 的消费方。它不渲染 UI,也不了解输入的收集方式;它只将模型参数转换为 `AskUserQuestionRequest`,并把用户回答返回给 agent loop(智能体循环)。 ## 模型体验 @@ -49,7 +49,7 @@ #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json index d2523ce220..7418c7ba2c 100644 --- a/packages/ui/tool-ask-user/package.json +++ b/packages/ui/tool-ask-user/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 724317c899..cf9c8e1fce 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce -README.zh.md: c15871073231b6e97f37fc0338f4824025ba86ca +README.zh.md: 3c02f8d673a62c3ee954da26f6341b433032a88c diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index c158710732..3c02f8d673 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -10,7 +10,7 @@ `ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。 -工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +工具流水线通过此 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)。 ## 模型体验 @@ -52,7 +52,7 @@ Approval prompts are disabled in this session: actions that require approval are #### KV Cache 影响 -仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/ui/user-approval/package.json b/packages/ui/user-approval/package.json index 0cc005cfa1..5b5c43cb42 100644 --- a/packages/ui/user-approval/package.json +++ b/packages/ui/user-approval/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 793a7a4693..6af9de6428 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { CallId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, type CallId } 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' @@ -59,8 +59,8 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the cache-safe runtime-context snapshot). The LAST such - * event is the session's override ({@link effectiveApprovalPolicy}). + * from the runtime-context snapshot and live switch notices). The LAST + * such event is the session's override ({@link effectiveApprovalPolicy}). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -188,7 +188,7 @@ export interface Config { /** * Approval service that applies session policy before answerers and logs every * ask/outcome pair to the requesting session. It exposes deterministic policy - * changes to the model through the cache-safe runtime-context snapshot. + * changes to the model through the runtime-context snapshot and switch notices. */ export class ApprovalService extends Service { static Config: z<Config> = z.object({ @@ -217,6 +217,26 @@ export class ApprovalService extends Service { }) } + /** + * Switch one live agent's policy and queue the transition for its next model + * step. Session initialization uses {@link setApprovalPolicy} directly + * because there is no previously visible policy to change. + * @param agent - the live agent whose policy is changing. + * @param policy - the new effective policy. + */ + setPolicy(agent: Agent, policy: ApprovalPolicy): void { + const previous = this.effectivePolicy(agent.session) + if (previous === policy) return + setApprovalPolicy(agent.session, policy) + agent.inject(createUserMessage({ + content: [{ + type: 'text', + text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`, + }], + source: { kind: 'plugin', plugin: 'user-approval' }, + })) + } + /** * Ask the composed answerers to decide one readonly same-process request. * The service borrows the request, agent, session, and live signal directly. diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index ca54862ba4..2bb66caa9d 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -118,7 +118,7 @@ describe('ApprovalService.request', () => { await ctx.plugin(SessionStore) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('asked-observer-throw')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const agent = { session } as unknown as Agent const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.on('session/event', (_session, event) => { @@ -141,7 +141,7 @@ describe('ApprovalService.request', () => { await ctx.plugin(SessionStore) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('decided-observer-throw')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const agent = { session } as unknown as Agent const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.on('session/event', (_session, event) => { @@ -353,14 +353,14 @@ describe('approval policy (the approval/policy fold)', () => { const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' - /** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */ + /** + * An agent stand-in over a REAL Session — gate and context fold real events; + * the opened turn satisfies request()'s enclosure precondition. + */ function sessionAgent(id: string): { agent: Agent; session: Session } { - const session = new Session(SessionId(id)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const agent = { - id, - session, - } as unknown as Agent + const session = Session.create(SessionId(id)) + session.append('turn/start', { turn: 1 }) + const agent = { id, session } as unknown as Agent return { agent, session } } @@ -448,6 +448,27 @@ describe('approval policy (the approval/policy fold)', () => { await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') }) + it('queues a live policy switch for the next model step', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session } = sessionAgent('sess-policy-notice') + const inject = vi.fn<Agent['inject']>() + const liveAgent = { ...agent, inject } as Agent + + ctx.approval.setPolicy(liveAgent, 'never') + ctx.approval.setPolicy(liveAgent, 'never') + + expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(inject).toHaveBeenCalledOnce() + expect(inject.mock.calls[0]?.[0]).toMatchObject({ + content: [{ + type: 'text', + text: 'The approval policy changed from "ask" to "never" (changed by the user).', + }], + source: { kind: 'plugin', plugin: 'user-approval' }, + }) + }) + it('contributes the complete current ask or never policy as cache-safe context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -463,7 +484,7 @@ describe('approval policy (the approval/policy fold)', () => { expect(await contextFor({})).toBe('') }) - it('reflects the latest durable switch and stays byte-stable while unchanged', async () => { + it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ApprovalService) @@ -479,13 +500,13 @@ describe('approval policy (the approval/policy fold)', () => { expect(await contextFor()).toBe(NEVER_SENTENCE) }) - it('disposes the service context contribution with its fiber (HMR safety)', async () => { + it('disposes the runtime-context contribution with the service', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) const fiber = await ctx.plugin(ApprovalService) - const live = sessionAgent('sess-hmr-service-live') + const { agent } = sessionAgent('sess-hmr-service-live') const contextFor = async () => - (await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy') + (await ctx.systemPrompt.assemble({ agent })).contexts.find(context => context.name === 'approval:policy') expect(await contextFor()).toBeDefined() await fiber.dispose() expect(await contextFor()).toBeUndefined() diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index 924272a360..bf23b4106d 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -14,7 +14,7 @@ async function setup(): Promise<Context> { } function startTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) } describe('approval invariants', () => { @@ -32,7 +32,7 @@ describe('approval invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const id = ApprovalRequestId('ask-resume') session.append('approval/asked', { id, toolName: 'bash' }) await ctx.plugin(InvariantService) @@ -43,7 +43,7 @@ describe('approval invariants', () => { it('adopts a bare session first observed through publication', async () => { const ctx = await setup() - const session = new Session(SessionId('bare-approval-session')) + const session = Session.create(SessionId('bare-approval-session')) const id = ApprovalRequestId('bare-ask') const asked = { type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' }, @@ -54,7 +54,7 @@ describe('approval invariants', () => { expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, asked) ctx.emit('session/event', session, decided) diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 1909864c26..2fd2733c62 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-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/ui/user-interaction/README.md -README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416 -README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e +README.md: 7d3c4e87c018e42794d319b540fd0a94abc9b43c +README.zh.md: 0e5d15a673c124abab4b13e869623df1a5c63acd diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index c7fec590d6..7d3c4e87c0 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -28,7 +28,7 @@ For a single-select question, `custom` overrides the selected choice and `select ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; the Web host runtime provides the shipped interactive implementation. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. ## Model Experience diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 340af3541a..0e5d15a673 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -24,11 +24,11 @@ ### 呈现意图 -`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。 +`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 ## 职责 -这是接口包(package)。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 +这是接口包。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;Web 宿主运行时提供随产品交付的交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 ## 模型体验 diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json index 07522d1d1a..5bed79e075 100644 --- a/packages/ui/user-interaction/package.json +++ b/packages/ui/user-interaction/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index add6070a27..82e5f323d8 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9 -README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77 +README.md: b84f2cbb88981aa2edbde65d8a711b28cc5b48e9 +README.zh.md: 44834041aad8d891b5dcaf63e02be5641e878cdc diff --git a/packages/util/README.md b/packages/util/README.md index 46904aba70..b84f2cbb88 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -2,21 +2,13 @@ English | [中文](README.zh.md) -Zero-dependency primitives shared across the other groups. A package lands here when it owns a tiny, foundational type or helper that several capability families need but that belongs to none of them — keeping it out of any one group avoids a capability package depending on an unrelated one just to reach a shared primitive. These are **support** packages: small, stable, and free of harness dependencies. +These zero-dependency packages provide small primitives shared by multiple capability families. Business semantics remain with each consuming capability. | Package | Role | |---|---| -| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | -| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | -| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | -| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | -| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | -| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | - -`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. - -`dsh-paths` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, telemetry, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. The harness keeps all user data under one root. - -`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - -`dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md)). +| [`brand/`](brand/README.md) | Provides nominally branded types | +| [`paths/`](paths/README.md) | Resolves the Harness data root and shared paths | +| [`timeout/`](timeout/README.md) | Provides deadline and timeout classification primitives | +| [`retention/`](retention/README.md) | Bounds retained text and item collections | +| [`atomic-write/`](atomic-write/README.md) | Replaces files atomically | +| [`native-command/`](native-command/README.md) | Runs host-native commands without a shell | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 59a2dcf792..44834041aa 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -2,21 +2,13 @@ [English](README.md) | 中文 -其他分组共享的零依赖原语。当某个微小的基础类型或辅助工具被多个功能家族所需,但又不属于任何一个家族时,它就位于此处。这样可避免一个功能包仅为使用共享原语而依赖不相关的功能包。这些都是**支持** 包:规模小、稳定,且不依赖 harness。 +这些零依赖包提供由多个能力家族共享的小型原语。业务语义仍归各个消费这些原语的能力所有。 | 包 | 职责 | |---|---| -| `brand/` | 仅包含类型的 `Branded<B>` 名义类型原语(无运行时代码,无 harness 依赖) | -| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | -| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | -| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | -| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | -| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | - -`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 - -`dsh-paths` 为每个包提供同一个可配置的 Harness 主目录,而不将这项横切事实归属给 bash、skill、telemetry 或组合 bundle。它优先解析显式值,其次是 `$DSH_HOME`,最后回退到 `~/.dsh`;返回绝对路径,但不缓存、创建或修改任何内容。harness 将所有用户数据保存在同一根目录下。 - -`dsh-timeout` 对超时家族采用相同结构:`dsh-bash` 和 `dsh-web-fetch-local` 都只依赖 `dsh-timeout`,便可将调用方的取消与 deadline 融合,然后区分「已超时」和「已取消」。它刻意只负责时序/分类部分,*终止*机制(对进程组发送 SIGKILL、拆除 fetch socket)保留在各个功能中,因为没有任何共享层可以负责每个功能的终止操作(见[超时库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - -`dsh-retention` 对有界工具输出采用同样的拆分方式:工具(`glob`/`grep`/`bash`/`web_fetch`/`web_search`)将项或文本送入 retainer,取回保留的内容以及被省略的精确内容;分组、退出码、提供方错误和恢复文案则仍由工具负责。它刻意只负责保留机制;`truncated` 是预算事实,绝不表示「检查不完整」状态(见[保留库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md))。 +| [`brand/`](brand/README.md) | 提供带名义品牌的类型 | +| [`paths/`](paths/README.md) | 解析 Harness 数据根目录和共享路径 | +| [`timeout/`](timeout/README.md) | 提供截止时间和超时分类原语 | +| [`retention/`](retention/README.md) | 限制保留文本和项目集合的大小 | +| [`atomic-write/`](atomic-write/README.md) | 以原子方式替换文件 | +| [`native-command/`](native-command/README.md) | 不经 shell 运行宿主原生命令 | diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index 822a0952ca..33e1f6014b 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md README.md: 2ff4abb6ac10d8b592ccd2056b4f1f92cc8518b0 -README.zh.md: bd5d3f1f2583ff1b97a80ce2c1c6ca7989e13d15 +README.zh.md: 4284d06422564268bb9e31d1a1ed06ab5271e562 diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index bd5d3f1f25..4284d06422 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -30,15 +30,15 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 -## Model Experience +## 模型体验 无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 -#### KV Cache effect +#### KV Cache 影响 无;此处没有任何内容会进入请求前缀。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 - **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 147ecb1e05..00de333170 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/brand/README.i18n.yaml b/packages/util/brand/README.i18n.yaml index 2c9d24d392..5736364ee1 100644 --- a/packages/util/brand/README.i18n.yaml +++ b/packages/util/brand/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/brand/README.md README.md: 68401d95a31ed2122386794ad5256a5cd93bb2a6 -README.zh.md: 98fcad0487af3ba1c2aaada4716744fb6088cdfe +README.zh.md: 0eeb2c8afb5fe1b279f49c839e769b8d0eea0a5a diff --git a/packages/util/brand/README.zh.md b/packages/util/brand/README.zh.md index 98fcad0487..0eeb2c8afb 100644 --- a/packages/util/brand/README.zh.md +++ b/packages/util/brand/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`Branded<B>` 名义类型原语:一个微小的**仅类型**包(package),无运行时代码,也不依赖其他 harness 包;所有负责跨边界 id 的包都会共享它。 +`Branded<B>` 名义类型原语:一个微小的**仅类型**包,无运行时代码,也不依赖其他 harness 包;所有负责跨边界 id 的包都会共享它。 ## `Branded` 是什么 diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 51ce4d795d..83676d1d5c 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index b57a63ef98..237ad82299 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6 -README.zh.md: 4bc66c4047194de03f236fa7591dad88e5c3fb57 +README.md: dd9d1ddaf817b2ff77ec0cd01710053e918b4296 +README.zh.md: 3ff8a392f2c511295adb0bf5f62b6e0527cbaea6 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 7fc8b1f464..dd9d1ddaf8 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything. -Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests. +Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is their injectable command boundary. It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 4bc66c4047..3ff8a392f2 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -2,26 +2,26 @@ [English](README.md) | 中文 -宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 +宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接 spawn 可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时,调用会以错误拒绝;该错误附带退出 `code` 与两路已捕获输出,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 -它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。 +它的两个消费方都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关将路径交由默认应用打开的操作([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方的可注入命令边界。 它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。 -## Surface +## 接口面 ```ts import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' ``` -## Model Experience +## 模型体验 无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。 -#### KV Cache effect +#### KV Cache 影响 -无;该包既不组装也不发送 provider 请求。 +无;该包既不组装也不发送提供方请求。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index a20e61c33d..d282a128f7 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index a61962d853..f79f6b2d73 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/paths/README.md README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c -README.zh.md: 75cc22581e0e0c5ab18573eb0785251243009121 +README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index 75cc22581e..7fe0ec0411 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 -该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 +该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 ## 已知限制与暂缓事项 diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index 601a2941b2..cece1ce79e 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/retention/README.i18n.yaml b/packages/util/retention/README.i18n.yaml index dc9529004b..b9ba96e765 100644 --- a/packages/util/retention/README.i18n.yaml +++ b/packages/util/retention/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/util/retention/README.md -README.md: d257075a67b35e92bce53a88fc6d002f4f4d5d9b -README.zh.md: c97c1d5afe504c54851f35b86d7bb893b32bcea4 +README.md: 45c88e9fc2d8df98f935fd1c3b43c4622802bd2a +README.zh.md: 0fcb76a8ae381d3e3da54e90640a38cba9de12e8 diff --git a/packages/util/retention/README.md b/packages/util/retention/README.md index d257075a67..45c88e9fc2 100644 --- a/packages/util/retention/README.md +++ b/packages/util/retention/README.md @@ -47,7 +47,7 @@ Text caps and `omittedBytes` count **bytes**, for process/body safety (a child's ## Tool mappings -Every current retention consumer maps to the library below. A broad migration is out of scope for the library's first landing — these are the intended shapes. +Current retention consumers use these mappings: | Tool | Retainer & strategy | Notes | |---|---|---| @@ -57,7 +57,7 @@ Every current retention consumer maps to the library below. A broad migration is | `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. | | `web_search` | `ItemRetainer<WebSearchSource>`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. | -`read` is **intentionally out of scope for v1.** Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, a byte cap over the selected window — which is a line-window renderer, not generic retention. A single `Omitted` count cannot represent both sides of a line window. +`read` remains outside this generic 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 byte cap over the selected window — which is a line-window renderer. A single `Omitted` count cannot represent both sides of that window. ## Usage shape diff --git a/packages/util/retention/README.zh.md b/packages/util/retention/README.zh.md index c97c1d5afe..0fcb76a8ae 100644 --- a/packages/util/retention/README.zh.md +++ b/packages/util/retention/README.zh.md @@ -4,9 +4,9 @@ 一个轻依赖的**保留**库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。 -该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。 +该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。 -它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包(package)直接导入它。 +它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。 ## 对外接口 @@ -47,7 +47,7 @@ import type { ## 工具映射 -当前每个保留消费方都按下表映射到该库。广泛迁移不属于该库首次落地的范围;下表是预期形态。 +当前的保留机制消费方采用以下映射: | 工具 | Retainer 与策略 | 说明 | |---|---|---| @@ -57,7 +57,7 @@ import type { | `web_fetch` | `TextRetainer`,`head` 或 `headTail` | 提供方/资源上限保留为提供方事实;retainer 只提供保留文本和省略元数据。 | | `web_search` | `ItemRetainer<WebSearchSource>`,`head` | 当提供方返回的来源超过面向模型的结果应包含的数量时,标准化「来源已达上限」通知。 | -`read` **刻意不在 v1 范围内**。其 `read-render` 辅助工具负责文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断、针对已选窗口的字节上限。这是行窗口渲染器,而非通用保留机制。单个 `Omitted` 数量无法表示行窗口两侧。 +`read` 仍不属于这个通用库。其 `read-render` 辅助工具负责文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断,以及所选窗口的字节上限。这是行窗口渲染器。单个 `Omitted` 数量无法表示该窗口两侧。 ## 使用形态 diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index 312bebd624..80af828263 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index 0436cc4d34..de4a85e9c9 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/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/util/timeout/README.md -README.md: 11c55a45a1255e14fb551e42ba3965453dbd94ae -README.zh.md: 8b63f00595139f9af2e9a31e8ab3c3494088e0ff +README.md: 0ff5550ef7ea6b8315a6a529a4b8b503162a8f12 +README.zh.md: 79d7ee674209b0324ae9428c1a04fda8a2547db5 diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index 11c55a45a1..0ff5550ef7 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -46,7 +46,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist. -Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired. +Pass your own `code` to `timeoutOf` so classification composes under nesting. When `upstream` is itself a deadline signal, `AbortSignal.any` preserves its `TimeoutReason` if that timer fires first. Scoping to your code makes a foreign timeout read as an ordinary upstream cancel instead of claiming that the local timer expired. For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index 8b63f00595..79d7ee6742 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -4,7 +4,7 @@ 超时的**时序与分类**部分:一个零依赖纯函数库(无运行时 harness 依赖),由每个需要限制调用方超时提示、启动 deadline,并在之后区分「已超时」与「已取消」的功能共享。 -它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各功能负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。 +它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各功能负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。 它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不持有状态,也不发出事件。「超时服务」必须了解如何停止每项功能的工作,这正是微内核要排除在共享层之外的知识。 @@ -46,7 +46,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout 该信号只会*通知*;调用方必须接入自己的终止机制(`d.signal.addEventListener('abort', kill)`,或将 `d.signal` 传给 `fetch`)。让 promise 与 timer 竞速,会在子进程或套接字仍在泄漏时就让工具调用完成;发出信号则会强制要求存在真正的终止路径。 -将你自己的 `code` 传给 `timeoutOf`,以便分类可在嵌套中组合:当你收到的 `upstream` *本身*就是 deadline 信号时(未来启动每次调用 deadline 的 `tools/execute` 中间件),如果外层 timer 首先触发,`AbortSignal.any` 会保留外层 `TimeoutReason`。将范围限定为你的 `code`,可将外部超时视为普通 upstream 取消,这才是你所属功能视角下的正确分类,而不会在本地 timer 尚未到期时就声称自己超时。 +将你自己的 `code` 传给 `timeoutOf`,使分类可在嵌套场景中正确组合。当 `upstream` 本身是 deadline 信号时,如果该 timer 先触发,`AbortSignal.any` 会保留它的 `TimeoutReason`。将匹配范围限定为你的 code,会把外部超时视为普通的 upstream 取消,而不会声称本地 timer 已到期。 对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 615d21759e..853cee4d79 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml index 636e5ddd63..cd412d1a99 100644 --- a/packages/web/README.i18n.yaml +++ b/packages/web/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/web/README.md -README.md: 8cd173b922ead32219ffab2b2d6b6428b3ce375c -README.zh.md: 42f09e19d0faf709cc4f669ae5f7835b809a79a5 +README.md: 65b3dde1e2f9c35c308a10ca80245064f7a0361e +README.zh.md: 43b8b7d9c53336e984d9c61fa9b612fc1733e31f diff --git a/packages/web/README.md b/packages/web/README.md index 8cd173b922..65b3dde1e2 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -1,18 +1,16 @@ -# web/ - web capability family +# web/ — web capability family English | [中文](README.zh.md) -The web access capability seam: an abstract web interface, search/fetch provider implementations, and the model-facing web tools. All **product** packages. +This family provides provider-neutral web search and fetch operations plus the model-facing tools that consume them. | Package | Role | ctx key | |---|---|---| -| `web/` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | -| `web-search-exa/` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | -| `web-search-perplexity/` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | -| `web-search-deepseek/` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) | -| `web-fetch-local/` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | -| `tool-web/` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | +| [`web/`](web/README.md) | Defines web provider registration, selection, and shared errors | `ctx.web` | +| [`web-search-exa/`](web-search-exa/README.md) | Provides web search through Exa | registers on `ctx.web` | +| [`web-search-perplexity/`](web-search-perplexity/README.md) | Provides web search through Perplexity | registers on `ctx.web` | +| [`web-search-deepseek/`](web-search-deepseek/README.md) | Provides native DeepSeek web search | registers on `ctx.web` | +| [`web-fetch-local/`](web-fetch-local/README.md) | Fetches public HTTP and HTTPS resources | registers on `ctx.web` | +| [`tool-web/`](tool-web/README.md) | Exposes web search and fetch to the model | registers on `ctx.tools` | -The interface lives at `web/web/`. Unlike bash/fs, the seam spans **two capabilities** (search and fetch) with potentially multiple providers each: `ctx.web` is one web-access middle layer with one provider-selection policy, one abort/error vocabulary, and one product-facing "how this harness reaches the web" config surface. Providers register **capabilities**, not tools; `tool-web` is the only owner of model-facing names, schemas, prompt guidance, and presentation. A search provider swap does not change how the model asks for a query, and a fetch implementation swap does not change how the model asks for a URL. - -See the [web capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) for the design rationale, including why search and fetch are deliberately one seam and why `web_fetch`'s SSRF protection is deferred. +The [web capability decision](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) records why search and fetch share one provider-selection service. diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md index 42f09e19d0..43b8b7d9c5 100644 --- a/packages/web/README.zh.md +++ b/packages/web/README.zh.md @@ -1,18 +1,16 @@ -# web/ - web 能力家族 +# web/:web 能力家族 [English](README.md) | 中文 -web 访问能力 seam:抽象 web 接口、搜索/抓取提供方实现,以及面向模型的 web 工具。这些全是**产品**包(package)。 +本家族提供与提供方无关的 web 搜索和抓取操作,以及消费这些操作的面向模型工具。 | 包 | 职责 | ctx key | |---|---|---| -| `web/` | 抽象 web seam(搜索/抓取提供方注册表 + 选择 + 词汇 + `WebError`) | `ctx.web` | -| `web-search-exa/` | Exa 支持的 `WebSearchProvider` | (注册到 `ctx.web`) | -| `web-search-perplexity/` | Perplexity 支持的 `WebSearchProvider` | (注册到 `ctx.web`) | -| `web-search-deepseek/` | DeepSeek 支持的 `WebSearchProvider`,通过 Anthropic 兼容 API 使用原生 `web_search` | (注册到 `ctx.web`) | -| `web-fetch-local/` | 用于匿名访问公共 HTTP(S) 的 `WebFetchProvider` | (注册到 `ctx.web`) | -| `tool-web/` | 面向模型的 `web_search`/`web_fetch` 工具 schema | (注册到 `ctx.tools`) | +| [`web/`](web/README.md) | 定义 web 提供方注册、选择和共享错误 | `ctx.web` | +| [`web-search-exa/`](web-search-exa/README.md) | 通过 Exa 提供 web 搜索 | 注册到 `ctx.web` | +| [`web-search-perplexity/`](web-search-perplexity/README.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` | +| [`web-search-deepseek/`](web-search-deepseek/README.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` | +| [`web-fetch-local/`](web-fetch-local/README.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` | +| [`tool-web/`](tool-web/README.md) | 向模型公开 web 搜索和抓取 | 注册到 `ctx.tools` | -接口位于 `web/web/`。与 bash/fs 不同,该 seam 跨越**两种能力**(搜索和抓取),每种能力都可能有多个提供方:`ctx.web` 是单一的 web 访问中间层,拥有一项提供方选择策略、一套中止/错误词汇,以及一个面向产品的「该 harness 如何访问 web」配置接口。提供方注册的是**能力**而非工具;`tool-web` 是面向模型名称、schema、提示词指引和呈现的唯一负责方。替换搜索提供方不会改变模型提出查询的方式,替换抓取实现也不会改变模型请求 URL 的方式。 - -设计原理见 [web 能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),其中也解释了搜索与抓取为何有意合并为一个 seam,以及为何暂缓实现 `web_fetch` 的 SSRF 防护。 +[web 能力决策](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)记录了搜索和抓取共用一项提供方选择服务的原因。 diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 590b319209..8f2ccbd057 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/tool-web/README.md README.md: 12f5c806db66b2109888c1ec642d117f3432d0df -README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec +README.zh.md: 27b4bc54a03af6347783a9666bd926bdc74fd0c8 diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index cfbf47219f..27b4bc54a0 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 +面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。 @@ -101,7 +101,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 抓取结果 @@ -115,7 +115,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ### 参数错误 @@ -129,10 +129,10 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 -- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 +- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。 diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 9e1a54b6cd..d5d655b2dd 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-fetch-local/README.i18n.yaml b/packages/web/web-fetch-local/README.i18n.yaml index dc8929c47e..0e487b6ef5 100644 --- a/packages/web/web-fetch-local/README.i18n.yaml +++ b/packages/web/web-fetch-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-fetch-local/README.md README.md: 8cadba2de7a2708252ebc7143840825fd4fe4549 -README.zh.md: e4ae567d9b763d0fc1192b59d89ac5304c1c34de +README.zh.md: b3f0707c448da7abaaafe2cdcf2520914ffff932 diff --git a/packages/web/web-fetch-local/README.zh.md b/packages/web/web-fetch-local/README.zh.md index e4ae567d9b..b3f0707c44 100644 --- a/packages/web/web-fetch-local/README.zh.md +++ b/packages/web/web-fetch-local/README.zh.md @@ -4,7 +4,7 @@ 一个匿名公共 HTTP(S) `WebFetchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它获取具体 URL,返回状态码和长度受限的解码内容。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。它是函数/命名空间插件(`inject: ['web']`)。 +这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。它是函数/命名空间插件(`inject: ['web']`)。 ## 职责拆分 @@ -46,6 +46,6 @@ ## 已知限制与暂缓事项 -- **SSRF/私有网络防护暂缓**:不会阻止私有、loopback、link-local、multicast 或其他非公开目标,也不进行 DNS 解析后验证或逐跳重新验证(见 [web 能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。在此功能落地前,该提供方是 SSRF 原语;能够访问敏感内部网络目标的部署**禁止启用它**。 +- **SSRF/私有网络防护暂缓**:不会阻止私有、loopback、link-local、multicast 或其他非公开目标,也不进行 DNS 解析后验证或逐跳重新验证(见 [web 能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。在此功能落地前,该提供方是 SSRF 原语;能够访问敏感内部网络目标的部署**禁止启用它**。 - **只解码文本内容**:包括 html/xhtml 与 `text/*` 加 JSON/XML 家族;缺少 `Content-Type` 或任何二进制类型都会抛出 `WEB_UNSUPPORTED_CONTENT_TYPE`,可提取文本的 PDF 解码属于明确的暂缓工作。 - **charset 只来自 `Content-Type` 标头**(默认为 UTF-8):HTML `<meta charset>` 声明会被忽略;声明但无法识别的 charset 标签会抛出异常,而非回退。 diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 5c42bfa09c..e6dc5791d9 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index edc7b5d18b..96c182e552 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md README.md: 9046934de209ed0787efa50332e5be16bfdf55c6 -README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd +README.zh.md: 1265fbd1ccda55374559f4633ca6a947bd04a6fb diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 94e01daba6..1265fbd1cc 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -4,7 +4,7 @@ 由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 +这是一个**实现**包:它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 ## 与专用搜索端点的区别 @@ -74,7 +74,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index e1dbf720f6..546ca2ec87 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-exa/README.i18n.yaml b/packages/web/web-search-exa/README.i18n.yaml index e9d70dc2a8..9adda78384 100644 --- a/packages/web/web-search-exa/README.i18n.yaml +++ b/packages/web/web-search-exa/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-exa/README.md README.md: c24f952eede90aae6fa23eec976255cb99625c19 -README.zh.md: 9b42c81d1fb08ac781dd8f4721bfc70fb19cfcea +README.zh.md: 58255a564601ae4d57f1916507a76a55cdbabf44 diff --git a/packages/web/web-search-exa/README.zh.md b/packages/web/web-search-exa/README.zh.md index 9b42c81d1f..58255a5646 100644 --- a/packages/web/web-search-exa/README.zh.md +++ b/packages/web/web-search-exa/README.zh.md @@ -4,7 +4,7 @@ 由 [Exa](https://exa.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Exa 的 `POST /search` 端点并请求高亮摘要内容,把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有 `ctx.web` 键,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`),负责注册后端,而非默认导出服务。 +这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有 `ctx.web` 键,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`),负责注册后端,而非默认导出服务。 ## 配置 @@ -38,5 +38,5 @@ Exa 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。 ## 已知限制与暂缓事项 - **没有非空白高亮摘要的结果会被整个丢弃**:没有可映射的可移植 snippet,因此返回源可能少于请求数量。 -- **只公开 `searchType`/`numResults`/`highlightsPerResult`**:Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关 seam 字段(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **只公开 `searchType`/`numResults`/`highlightsPerResult`**:Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关 seam 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 - **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 7d6b802d2e..7bffc94331 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-perplexity/README.i18n.yaml b/packages/web/web-search-perplexity/README.i18n.yaml index 8da0df01bc..6d5f34e989 100644 --- a/packages/web/web-search-perplexity/README.i18n.yaml +++ b/packages/web/web-search-perplexity/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-perplexity/README.md README.md: 80f6d34d63ddf0cc7d1f0d4f6744d64c91f9a269 -README.zh.md: 7bb3721eedb2da27ee881f86f10368bec8be57a9 +README.zh.md: 485ba992a6fbc7bb6e4bb4a6753b7c34e9f61292 diff --git a/packages/web/web-search-perplexity/README.zh.md b/packages/web/web-search-perplexity/README.zh.md index 7bb3721eed..485ba992a6 100644 --- a/packages/web/web-search-perplexity/README.zh.md +++ b/packages/web/web-search-perplexity/README.zh.md @@ -4,7 +4,7 @@ 由 [Perplexity](https://perplexity.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Perplexity 的 OpenAI 兼容 `POST /chat/completions` 端点,把生成答案与引用映射为 seam 规范化的 `WebSearchResult`。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。OpenAI 兼容协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 +这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。OpenAI 兼容协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 ## 配置 @@ -55,11 +55,11 @@ #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 - **引用回退源只含 URL**:Perplexity 省略结构化 `search_results[]` 时,源不含 `title`/`snippet`/`publishedAt`,因此工具只渲染纯主机名标签。 - **超量返回的来源仍会增加 token 消耗和延迟**:协议没有结果数量控制,`maxResults` 只能由 seam 在事后截断。 -- **只公开 `model`/`maxTokens`/`searchRecency`**:Perplexity 的其他搜索控制项(域名过滤条件、`web_search_options` 上下文大小、图片)等待提供方无关 seam 字段(见 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **只公开 `model`/`maxTokens`/`searchRecency`**:Perplexity 的其他搜索控制项(域名过滤条件、`web_search_options` 上下文大小、图片)等待提供方无关 seam 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 - **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9aa7080431..a6f6a6bea6 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml index 7c2338f169..fe4c307b3b 100644 --- a/packages/web/web/README.i18n.yaml +++ b/packages/web/web/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web/README.md README.md: 73765fe060cc0a2b0fa3d69703a670f488a29ac9 -README.zh.md: 49aef50bc678b523d5ee44d2bc4a015106ea7ae1 +README.zh.md: 3cce0fb528457b566b2c2ab4ffd1f29471255c07 diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md index 49aef50bc6..3cce0fb528 100644 --- a/packages/web/web/README.zh.md +++ b/packages/web/web/README.zh.md @@ -4,7 +4,7 @@ **web 访问 seam**:抽象 `WebService`(`ctx.web`)定义 harness 具备哪些 web 访问能力(搜索 web、抓取 URL),并通过多个提供方实现,不把模型契约绑定到某个厂商的 API 形状。 -该包(package)是 web 能力中负责接口的三分之一。与 bash/fs 不同,它在一个 seam 上跨越搜索与抓取两种能力,每种能力都可能有多个提供方: +该包是 web 能力中负责接口的三分之一。与 bash/fs 不同,它在一个 seam 上跨越搜索与抓取两种能力,每种能力都可能有多个提供方: | 包 | 职责 | |---|---| @@ -55,7 +55,7 @@ ## 已知限制与暂缓事项 -- **没有观测接口**:没有提供方变更事件或能力状态查询;可用性只能通过执行 `search()`/`fetch()` 并按抛出的 `WebError` code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不会枚举逐提供方原因(见 [Agent Note(agent 决策记录)](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 +- **没有观测接口**:没有提供方变更事件或能力状态查询;可用性只能通过执行 `search()`/`fetch()` 并按抛出的 `WebError` code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不会枚举逐提供方原因(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 - **`WebSearchRequest` 只携带 `query` + `maxResults`**:提供方无关的控制项(新近程度、域名过滤条件、区域提示、搜索深度)暂缓至 Exa 与 Perplexity 都能诚实支持时(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 - **`WebFetchBody` 没有 `pdf` 分支**:可提取文本的 PDF 支持属于明确的暂缓工作;封闭联合会使新增该分支成为三个 web 包中由编译强制执行的变更。 - **提供方支持的页面提取不属于 `fetch()` 范围**:Firecrawl/Tavily 风格的 `web_extract` 能力暂缓,而不会扩展抓取 seam。 diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 7b732ec819..4ea964ee93 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/README.i18n.yaml b/packages/workflow/README.i18n.yaml index cb4619569f..f02915a31d 100644 --- a/packages/workflow/README.i18n.yaml +++ b/packages/workflow/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/workflow/README.md -README.md: 17da3397e4fa64837b271d89e9062e3621434338 -README.zh.md: 1e424778781ba4f41c321af7fd4cb8b5159a49cb +README.md: 2416e26e73340a6d8624b8e77681dfc9f55fe7c5 +README.zh.md: a5c94a0a9799d78e5cca72394385b38d2fb2ba70 diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 17da3397e4..2416e26e73 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -2,15 +2,13 @@ English | [中文](README.zh.md) -The workflow seam: a model-written JavaScript orchestration script that fans out subagents at scale (phases, structured per-agent results, concurrency caps), modeled on Claude Code's dynamic workflows. A capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)) in the bash shape: ONE engine implementation per context registers as `ctx.workflows`; the model-facing tool consumes it. +This family runs model-authored orchestration workflows over subagents and exposes general and fixed-policy tools to the model. | Package | Role | ctx key | |---|---|---| -| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | -| `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | -| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | -| `tool-ralph/` | Fixed fresh-agent Ralph policy over `ctx.workflows` and a fresh structured-output subagent provider | (registers on `ctx.tools`) | +| [`workflow/`](workflow/README.md) | Defines workflow execution and lifecycle events | `ctx.workflows` | +| [`workflow-workerthread/`](workflow-workerthread/README.md) | Runs workflow scripts in worker threads | registers on `ctx.workflows` | +| [`tool-workflow/`](tool-workflow/README.md) | Exposes general workflow execution to the model | registers on `ctx.tools` | +| [`tool-ralph/`](tool-ralph/README.md) | Exposes the fixed fresh-agent Ralph workflow | registers on `ctx.tools` | -The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters. - -The general script engine's decisions and deferred work live in the [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). The separate [Ralph consumer](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode. +Worker threads isolate workflow execution from the host event loop but are not a security boundary. See the [dynamic-workflow](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md) and [Ralph tool](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) decisions. diff --git a/packages/workflow/README.zh.md b/packages/workflow/README.zh.md index 1e42477878..a5c94a0a97 100644 --- a/packages/workflow/README.zh.md +++ b/packages/workflow/README.zh.md @@ -1,16 +1,14 @@ -# workflow/:动态工作流能力族 +# workflow/:动态工作流能力家族 [English](README.md) | 中文 -工作流 seam:由模型编写 JavaScript 编排脚本,大规模扇出 subagent(分阶段、每个 agent(智能体)的结构化结果、并发上限),其设计参考 Claude Code 动态工作流。这是 bash 形态的能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):每个上下文只有一个引擎实现注册为 `ctx.workflows`;面向模型的工具使用它。 +本家族通过 subagent 运行由模型编写的编排工作流,并将通用工具与固定策略工具公开给模型。 -| 包(package) | 角色 | ctx 键 | +| 包 | 职责 | ctx 键 | |---|---|---| -| `workflow/` | 抽象工作流 seam:服务基类、运行词汇和 `workflow/*` 事件 | `ctx.workflows` | -| `workflow-workerthread/` | `node:worker_threads` 引擎:每次运行使用一个 worker;脚本的 vm 上下文位于 worker 内,`agent()` 通过消息端口桥接到 `ctx.subagents` | (提供 `ctx.workflows`) | -| `tool-workflow/` | 面向模型的 `workflow` 工具,基于 `ctx.workflows` | (注册到 `ctx.tools`) | -| `tool-ralph/` | 基于 `ctx.workflows` 和全新结构化输出 subagent 提供方的固定全新 agent Ralph 策略 | (注册到 `ctx.tools`) | +| [`workflow/`](workflow/README.md) | 定义工作流执行和生命周期事件 | `ctx.workflows` | +| [`workflow-workerthread/`](workflow-workerthread/README.md) | 在线程中运行工作流脚本 | 注册到 `ctx.workflows` | +| [`tool-workflow/`](tool-workflow/README.md) | 向模型公开通用工作流执行 | 注册到 `ctx.tools` | +| [`tool-ralph/`](tool-ralph/README.md) | 公开使用全新 agent(智能体)的固定 Ralph 工作流 | 注册到 `ctx.tools` | -接口位于 `workflow/workflow/`。引擎的 `agent()` 钩子使用 [subagent seam](../subagent/README.md)(任何已注册提供方;随产品交付的示例使用 `spawn`),`agent({ schema })` 则使用进程内后端实现的结构化输出支持。worker thread 隔离的是脚本:宿主绝不会被它阻塞,已取消运行经过宽限时间后的终止也会实际生效;但它不是安全边界。如果将来确有需要,可以在同一接口背后换用 isolated-vm/独立进程引擎,以实现真正的沙箱隔离。 - -通用脚本引擎的决策和暂缓事项见[动态工作流 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。独立的 [Ralph 消费方](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)会固定脚本和全新提供方策略,而不是再添加一个引擎或 agent loop(智能体循环)模式。 +worker thread 将工作流执行与宿主事件循环隔离,但不构成安全边界。参见[动态工作流](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)和 [Ralph 工具](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)决策。 diff --git a/packages/workflow/tool-ralph/README.i18n.yaml b/packages/workflow/tool-ralph/README.i18n.yaml index 5bde7f1f02..6c560978cb 100644 --- a/packages/workflow/tool-ralph/README.i18n.yaml +++ b/packages/workflow/tool-ralph/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-ralph/README.md README.md: daf242364d1093cff30cd1dc95823e1ecb89a9c7 -README.zh.md: db9564da1d1b7d8eabc67ba14c9d873201ac2807 +README.zh.md: c855c6b5f42728f67935208b44601b379364aa7f diff --git a/packages/workflow/tool-ralph/README.zh.md b/packages/workflow/tool-ralph/README.zh.md index db9564da1d..c855c6b5f4 100644 --- a/packages/workflow/tool-ralph/README.zh.md +++ b/packages/workflow/tool-ralph/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 `ralph` 工具运行固定的前台工作流,把一个不可变目标依次交给多个全新子 agent(智能体)。它展示如何把专用编排策略实现为基于 [`ctx.workflows`](../workflow/README.md) 和 [`ctx.subagents`](../../subagent/subagent/README.md) 的普通插件:不会向 `agent-loop` 添加 Ralph 模式或全新 agent loop(智能体循环),同会话的[目标领域](../../goal/goal/README.md)也保持独立。策略和暂缓事项由 [Ralph Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)负责。 +面向模型的 `ralph` 工具运行固定的前台工作流,把一个不可变目标依次交给多个全新子 agent(智能体)。它展示如何把专用编排策略实现为基于 [`ctx.workflows`](../workflow/README.md) 和 [`ctx.subagents`](../../subagent/subagent/README.md) 的普通插件:不会向 `agent-loop` 添加 Ralph 模式或全新 agent loop(智能体循环),同会话的[目标领域](../../goal/goal/README.md)也保持独立。策略和暂缓事项由 [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)负责。 ## 契约 diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index fb12147445..83bf0d057c 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 902438506c..3c104614bc 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md README.md: 5afa68764bfe339377954f8912b6f9b795435be1 -README.zh.md: a322fa363803a10b06c66d1aae9b769fad8e5c22 +README.zh.md: 9af11811c9a91562983dcd865a0cb1a626cdac3c diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index a322fa3638..9af11811c9 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包(package)负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 ## 模型看到的内容 @@ -14,7 +14,7 @@ ## 渲染意图 -渲染意图预先确定(见[渲染意图 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: <meta.name>`,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 +渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: <meta.name>`,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 ## 配置 @@ -71,7 +71,7 @@ Use the <toolName> tool ONLY when the user explicitly asks for a workflow or for #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index dd055ad9ec..705e4f6e08 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/workflow-workerthread/README.i18n.yaml b/packages/workflow/workflow-workerthread/README.i18n.yaml index f0aa44859c..b677e2e9e6 100644 --- a/packages/workflow/workflow-workerthread/README.i18n.yaml +++ b/packages/workflow/workflow-workerthread/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/workflow-workerthread/README.md README.md: 9da420bdd67ac5b0a4bfffa312c6fe7b2dabf8f5 -README.zh.md: cc2ed2043db7f6a51088930c07c5cf5a2223f480 +README.zh.md: caba1cf5284c1586fe17e00ed4115ff12e3e4ae9 diff --git a/packages/workflow/workflow-workerthread/README.zh.md b/packages/workflow/workflow-workerthread/README.zh.md index cc2ed2043d..caba1cf528 100644 --- a/packages/workflow/workflow-workerthread/README.zh.md +++ b/packages/workflow/workflow-workerthread/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)为 `WorkflowService` 提供实现,每次运行使用一个 Node worker thread。worker 执行编排脚本;子 agent(智能体)留在宿主上,脚本通过带类型的宿主/worker 协议经由 `ctx.subagents` 访问它们。 +本包为 `WorkflowService` 提供实现,每次运行使用一个 Node worker thread。worker 执行编排脚本;子 agent(智能体)留在宿主上,脚本通过带类型的宿主/worker 协议经由 `ctx.subagents` 访问它们。 包根目录默认导出引擎插件及其 `Config`;worker 协议、运行时和会话模块均为实现私有。操作入口 `./worker` 仍是引擎的 spawn 目标。 @@ -113,7 +113,7 @@ worker 错误、消息失败或提前退出会在清理前关闭消息接纳, #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index a915229449..e019bcaa51 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/workflow/README.i18n.yaml b/packages/workflow/workflow/README.i18n.yaml index d4daa2e30b..ac1d7c0506 100644 --- a/packages/workflow/workflow/README.i18n.yaml +++ b/packages/workflow/workflow/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/workflow/README.md README.md: 0de661423206cc71eb4669bc8ddb2419202bcb4a -README.zh.md: 5abda102a128f9c70cd0971e316c2f0c7f504871 +README.zh.md: da11b59aa285ea8f4e0e8c18ceab8922d706491a diff --git a/packages/workflow/workflow/README.zh.md b/packages/workflow/workflow/README.zh.md index 5abda102a1..da11b59aa2 100644 --- a/packages/workflow/workflow/README.zh.md +++ b/packages/workflow/workflow/README.zh.md @@ -24,7 +24,7 @@ - `workflow/phase` 和 `workflow/log` 公开脚本叙述; - `workflow/agent-start` / `workflow/agent-end` 按 `seq` 为每次子 agent 调用配对;提供方的异步启动调用被拒绝时,该子 agent 不会发出其中任何一个事件。 -同进程事件 payload 是以不可变方式借用的值。每个监听器都独立隔离:同步抛出异常或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变执行。 +同进程事件 payload 是借用的不可变值。每个监听器都独立隔离:同步抛出异常或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变执行。 ## 失败纪律 @@ -34,9 +34,9 @@ - `INVALID_ARGUMENT` / `UNSUPPORTED_OPTION` / `UNSUPPORTED_SCHEMA`:钩子调用违反引擎契约; - `AGENT_CAP` / `ITEM_CAP`:超过已配置的安全上限; - `AGENT_START`:提供方的异步启动调用被拒绝; -- `AGENT_RESULT`:已发布子 agent 的结果因基础设施故障而拒绝; +- `AGENT_RESULT`:已发布子 agent 的结果因基础设施故障而被拒绝; - `RESULT_UNSERIALIZABLE`:脚本/worker 值不是普通 JSON 数据; -- `CANCELLED`:取消会接管该运行,待处理和未来的钩子都会拒绝。 +- `CANCELLED`:取消会接管该运行,待处理和后续的钩子调用都会被拒绝。 子 agent 若以非完成的结束原因正常兑现,并不属于基础设施异常:`agent()` 返回 `null`,使脚本可以处理普通的子 agent 失败。 @@ -56,4 +56,4 @@ - **没有 token 预算词汇**:引擎会限制并发、条目和子 agent,但请求与结果都不会统计跨子 agent 的模型 token。 - **运行由持有方负责,不由服务跟踪**:卸载引擎不会发现独立的活动句柄;每个消费方都必须 dispose 自己启动的运行。 -暂缓实现的工作流接口见[动态工作流 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 +暂缓实现的工作流接口见[动态工作流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index b8ce1ebc7f..53ef7e6f6e 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workspace/README.i18n.yaml b/packages/workspace/README.i18n.yaml index b86bee367f..93dea4b3f0 100644 --- a/packages/workspace/README.i18n.yaml +++ b/packages/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/README.md -README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8 -README.zh.md: 2646a5fbcc9921362978fe129fe59d022efdd1c4 +README.md: 9f6ead776c1e5d36af5b6917ad8f27725b11e6a0 +README.zh.md: aa691ec681112f4c25a6391c33eabd2711c7abfc diff --git a/packages/workspace/README.md b/packages/workspace/README.md index ba92e95d3c..9f6ead776c 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -1,11 +1,11 @@ -# workspace/ — the workspace entity +# workspace/ — workspace entity family English | [中文](README.zh.md) -The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). +This family owns persistent workspaces: user directories with titles and ordered session membership. | Package | Role | ctx key | |---|---|---| -| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` | +| [`workspace/`](workspace/README.md) | Registers workspaces and accounts for their sessions | `ctx.workspace` | -Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)). +The [workspace package reference](workspace/README.md) owns lifecycle, persistence, and deletion semantics. diff --git a/packages/workspace/README.zh.md b/packages/workspace/README.zh.md index 2646a5fbcc..aa691ec681 100644 --- a/packages/workspace/README.zh.md +++ b/packages/workspace/README.zh.md @@ -1,11 +1,11 @@ -# workspace/:Workspace 实体 +# workspace/:workspace 实体家族 [English](README.md) | 中文 -Workspace 系列负责持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note(agent 决策记录)](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。 +本家族拥有持久 workspace:带标题和有序会话成员关系的用户目录。 | 包 | 职责 | ctx 键 | |---|---|---| -| `workspace/` | 基于存储领域数据形式的 `WorkspaceRegistry` 服务:按 realpath 去重的路径、会话归属记账、实体缓存 | `ctx.workspace` | +| [`workspace/`](workspace/README.md) | 注册 workspace 并记录其会话归属 | `ctx.workspace` | -所有权信息以 workspace 记录中的 `sessionIds`(有序)为准,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及会话归属记录:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 +[workspace 包参考](workspace/README.md)负责生命周期、持久化和删除语义。 diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 9ec33b56ca..aa3db1bef6 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md README.md: bfa044de50fc71dca95637487df50dc551d40e1b -README.zh.md: 1711e4d90c4cc62cf6e18a6868cd362a2c2e9458 +README.zh.md: f3be9611eba45db3719ce76ad6625f4787fab9ac diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 1711e4d90c..f3be9611eb 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领域数据形式存储持久 workspace 记录、稳定 workspace 顺序和按新到旧排列的候选会话索引。消费方看到 `Workspace` 接口;实体实现保持包(package)私有。 +DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领域数据形式存储持久 workspace 记录、稳定 workspace 顺序和按新到旧排列的候选会话索引。消费方看到 `Workspace` 接口;实体实现保持包私有。 -实体/存储理由见[领域 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md);仅使用头部的引导初始化和 GUI 排序见 [Workspace UI 产品流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md)。 +实体/存储理由见[领域 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md);仅使用头部的引导初始化和 GUI 排序见 [Workspace UI 产品流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md)。 ## 结构 diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 8ef0c34382..7839b29702 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ca290fd6..34c8d60cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.8.0 + istanbul-lib-report: + specifier: ^3.0.1 + version: 3.0.1 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -146,6 +149,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local @@ -305,6 +311,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -416,6 +425,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -479,6 +491,9 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 + node-addon-require-builtin: + specifier: ^0.1.4 + version: 0.1.4 devDependencies: '@types/js-yaml': specifier: ^4.0.9 @@ -511,6 +526,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -565,6 +583,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:* version: link:../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:* + version: link:../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -649,12 +670,18 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:* version: link:../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:* + version: link:../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:* version: link:../packages/guard/repeat-tool-guard '@deepseek-ai/dsh-repository-plugin': specifier: workspace:* version: link:../packages/cordis/repository-plugin + '@deepseek-ai/dsh-sandbox': + specifier: workspace:* + version: link:../packages/sandbox/sandbox '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../packages/sandbox/sandbox-local @@ -766,6 +793,9 @@ importers: '@deepseek-ai/dsh-tool-pty': specifier: workspace:* version: link:../packages/pty/tool-pty + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:* + version: link:../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph @@ -873,6 +903,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/bash-env: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash-local: dependencies: schemastery: @@ -928,6 +989,31 @@ importers: specifier: 0.0.0-test.0 version: 0.0.0-test.0 + packages/bash/pwsh-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -946,6 +1032,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local @@ -955,9 +1044,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -967,9 +1053,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -998,6 +1081,55 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/tool-pwsh: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': @@ -1018,6 +1150,9 @@ importers: schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery + ws: + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1025,6 +1160,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -1565,9 +1703,30 @@ importers: mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 + micromark-core-commonmark: + specifier: ^2.0.3 + version: 2.0.3 micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 + micromark-extension-math: + specifier: ^3.1.0 + version: 3.1.0 + micromark-factory-space: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + micromark-util-classify-character: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-symbol: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 react: specifier: ^18.2.0 version: 18.3.1 @@ -1924,6 +2083,9 @@ importers: packages/client/ui-trajectory: dependencies: + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff: specifier: ^9.0.0 version: 9.0.0 @@ -1946,12 +2108,18 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-workspace: dependencies: @@ -2204,6 +2372,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2213,6 +2384,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -2441,9 +2615,6 @@ importers: packages/core/agent: devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2686,6 +2857,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -3514,6 +3688,9 @@ importers: '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command + koffi: + specifier: ^3.1.0 + version: 3.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -3536,6 +3713,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + tsx: + specifier: ^4.19.2 + version: 4.22.4 packages/host/webserver: dependencies: @@ -3692,6 +3872,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4172,8 +4355,8 @@ importers: specifier: ^15.0.0 version: 15.0.0 node-addon-require-builtin: - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^0.1.4 + version: 0.1.4 devDependencies: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ @@ -6271,6 +6454,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local @@ -6626,8 +6812,8 @@ importers: specifier: ^1.8.1 version: link:../cosmokit node-addon-require-builtin: - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^0.1.4 + version: 0.1.4 pnpm: specifier: 11.7.0 version: 11.7.0 @@ -8740,6 +8926,15 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@tanstack/react-virtual@3.14.9': + resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8971,6 +9166,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/parser@8.61.0': resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -10844,56 +11042,56 @@ packages: resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} engines: {node: '>=20'} - node-addon-native-custom-loader@0.1.3: - resolution: {integrity: sha512-uMG8D3aOtEMgh7dkNWAJP0fSpmpMwUf6Cj5JePQQqtxt72sW7RDzRetaLjKIKl3+DZtBX2FobAgRS6U3LO2qXQ==} + node-addon-native-custom-loader@0.1.4: + resolution: {integrity: sha512-DreegO6EoC1JHWYBv3j8Miwp2Zl/CyBeNyoeyCbnEdjyYFEulR4Gcb3wj9fXF7KMDY0ZJ5MWwHcXP8GVNyScnA==} engines: {node: '>=20'} - node-addon-require-builtin-darwin-arm64@0.1.3: - resolution: {integrity: sha512-uBZIRpq3gVG/lg4SV1w8xNfgoaAWZiv7B8Gn38/wd0uUE0LSTRikY69al3Yb4gMDmJPWBoXPN3gzTxAjhllzGg==} + node-addon-require-builtin-darwin-arm64@0.1.4: + resolution: {integrity: sha512-pqiTPbqlDKRIo8YKoWMjuFge4kyHbsdYkAcGW5MAVcJSCyU0M1hhpiLdWlvX753XKL3xlGeuPmv2NqTNRV0/hw==} engines: {node: '>=20'} cpu: [arm64] os: [darwin] - node-addon-require-builtin-darwin-x64@0.1.3: - resolution: {integrity: sha512-BLaBoaBjI7mpsgTpXvn444vCQSmrb1AC2t0tAHFuxwBtN56UT9Zaxl+gS2KZGrq5fShTPxFAUIiTcwroFXWWhg==} + node-addon-require-builtin-darwin-x64@0.1.4: + resolution: {integrity: sha512-i9oThh+w6d+H79YQuc3d3MZCx4YZ6aMWlQ0HYMgOTWvSRzppXmksa/xPV8O20G1gjpq5do/9/hgImixj3XIcjQ==} engines: {node: '>=20'} cpu: [x64] os: [darwin] - node-addon-require-builtin-linux-arm64-gnu@0.1.3: - resolution: {integrity: sha512-L+qNUfBarYxE0HSZjf2KGymS6ZKMieLs5esRXbAbO+q1k4L1t9oBNGpQuFe7/a1a98YrfHnkAg9Q6US5j/xnIw==} + node-addon-require-builtin-linux-arm64-gnu@0.1.4: + resolution: {integrity: sha512-qbmYtkiIFp7h1ZvYYHH0MGFQbc03OyvplkeS90j+t3VMomkRc/YwZ140WzVM3eYJYZ7XHq+s1GL5ZdFQFPUXBQ==} engines: {node: '>=20'} cpu: [arm64] os: [linux] libc: [glibc] - node-addon-require-builtin-linux-x64-gnu@0.1.3: - resolution: {integrity: sha512-Cy2ua4yy44GE5HAtf/o4LjzTa5aUJt5m0YLMjZCa8lRte5hU+C7aWm6bVkmKY82b1JnnQnHwUMEoFugLqVybSQ==} + node-addon-require-builtin-linux-x64-gnu@0.1.4: + resolution: {integrity: sha512-4jC617+yOrYYuKgNmN2KMD722G6BICpjEzAcmzA3j5tt+zmey5M/c/z9JxqdjnNmU9CQWseBaJu/fGdbHZXSOg==} engines: {node: '>=20'} cpu: [x64] os: [linux] libc: [glibc] - node-addon-require-builtin-win32-arm64-msvc@0.1.3: - resolution: {integrity: sha512-8j/VcAmgT6HPQzwUo1kBNzLE2d5iVmwfraEre5KAoznuBeOiuU12oqDYpkuHGIzSjSDJiVOj/SqOe5mUMRaZOg==} + node-addon-require-builtin-win32-arm64-msvc@0.1.4: + resolution: {integrity: sha512-4TW96aPR108R3RxJbUNLXU6FLTZ7n8fWtSpPbPtvYWXa9cXojvCMdH4BvrHM6W2M+Iu42nqMxRyylP3PeTjOmA==} engines: {node: '>=20'} cpu: [arm64] os: [win32] - node-addon-require-builtin-win32-ia32-msvc@0.1.3: - resolution: {integrity: sha512-Iqh+Wxmbu6SaP2lEJpEpIMkusVZeVljn914CIcz7HZtzvxTgxHZAmfsZuRMDtRhDg2Yf4AFBHLWpcJn7SeBQ/A==} + node-addon-require-builtin-win32-ia32-msvc@0.1.4: + resolution: {integrity: sha512-a3ZRkiMKaE7uRjI+H+Ic7dvhPIk0rW9mHV47IEiqJzoRZqnDp5JPjQfAnngu979HR59Ghy+mfexJ/kStBlP3eQ==} engines: {node: '>=20 <23'} cpu: [ia32] os: [win32] - node-addon-require-builtin-win32-x64-msvc@0.1.3: - resolution: {integrity: sha512-5iI7C/BwwRemDNKXO2b1J/iK1gTRp1278Cwfoy92zgn7KXhv7xsAP8klk/fDu8RWX/o9zk736tRSFTBBGSHf/Q==} + node-addon-require-builtin-win32-x64-msvc@0.1.4: + resolution: {integrity: sha512-EGx7AcJKB7fNxo/YHV32JTUg1Hetbdnh6uPaqPhklhyRSMM13/7hZK1pTACqMS9dwnR3Lakxl9HKG9/eAoIyyg==} engines: {node: '>=20'} cpu: [x64] os: [win32] - node-addon-require-builtin@0.1.3: - resolution: {integrity: sha512-u9ZRdwDCx+ksIcYwoLeoe5Rj3151GrzSF8ln9jp7P/Zhf0OrPs1X6a8wYw6brc532ypAwjwKlhruT/V3m8MCbg==} + node-addon-require-builtin@0.1.4: + resolution: {integrity: sha512-yuXz43GmtQyMrO75u2Z8KZAafMhnMH8RTOZBJWGDU9HoD2QxT6q4PF28iLNm/OS9BkS8MHwCKpgTk3d6qW584A==} engines: {node: '>=20'} node-domexception@1.0.0: @@ -13767,6 +13965,14 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@tanstack/react-virtual@3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.17.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.17.7': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -14028,6 +14234,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 @@ -16292,54 +16502,54 @@ snapshots: node-addon-landlock-run-linux-arm64: 0.0.0-test.0 node-addon-landlock-run-linux-x64: 0.0.0-test.0 - node-addon-native-custom-loader@0.1.3: {} + node-addon-native-custom-loader@0.1.4: {} - node-addon-require-builtin-darwin-arm64@0.1.3: + node-addon-require-builtin-darwin-arm64@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-darwin-x64@0.1.3: + node-addon-require-builtin-darwin-x64@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-linux-arm64-gnu@0.1.3: + node-addon-require-builtin-linux-arm64-gnu@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-linux-x64-gnu@0.1.3: + node-addon-require-builtin-linux-x64-gnu@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-arm64-msvc@0.1.3: + node-addon-require-builtin-win32-arm64-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-ia32-msvc@0.1.3: + node-addon-require-builtin-win32-ia32-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-x64-msvc@0.1.3: + node-addon-require-builtin-win32-x64-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin@0.1.3: + node-addon-require-builtin@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optionalDependencies: - node-addon-require-builtin-darwin-arm64: 0.1.3 - node-addon-require-builtin-darwin-x64: 0.1.3 - node-addon-require-builtin-linux-arm64-gnu: 0.1.3 - node-addon-require-builtin-linux-x64-gnu: 0.1.3 - node-addon-require-builtin-win32-arm64-msvc: 0.1.3 - node-addon-require-builtin-win32-ia32-msvc: 0.1.3 - node-addon-require-builtin-win32-x64-msvc: 0.1.3 + node-addon-require-builtin-darwin-arm64: 0.1.4 + node-addon-require-builtin-darwin-x64: 0.1.4 + node-addon-require-builtin-linux-arm64-gnu: 0.1.4 + node-addon-require-builtin-linux-x64-gnu: 0.1.4 + node-addon-require-builtin-win32-arm64-msvc: 0.1.4 + node-addon-require-builtin-win32-ia32-msvc: 0.1.4 + node-addon-require-builtin-win32-x64-msvc: 0.1.4 node-domexception@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df156af96a..fec185b114 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,6 +61,15 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.82.1' + - node-addon-native-custom-loader@0.1.4 + - node-addon-require-builtin-darwin-arm64@0.1.4 + - node-addon-require-builtin-darwin-x64@0.1.4 + - node-addon-require-builtin-linux-arm64-gnu@0.1.4 + - node-addon-require-builtin-linux-x64-gnu@0.1.4 + - node-addon-require-builtin-win32-arm64-msvc@0.1.4 + - node-addon-require-builtin-win32-ia32-msvc@0.1.4 + - node-addon-require-builtin-win32-x64-msvc@0.1.4 + - node-addon-require-builtin@0.1.4 patchedDependencies: node-pty@1.1.0: patches/node-pty@1.1.0.patch diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index d0b46a7167..8dadf07570 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md -README.md: dfd9d909122f9245a19fe91d8a394156795b13ae -README.zh.md: b4b2b472e3164b5bf1fd2cfd77512de5be354f61 +README.md: 27637edb9d4d5e8714fe379a00b6aae3af1a541f +README.zh.md: dd020aa507de965815fc05f8423b87e766a2fb42 diff --git a/python/README.md b/python/README.md index dfd9d90912..27637edb9d 100644 --- a/python/README.md +++ b/python/README.md @@ -2,75 +2,19 @@ English | [中文](README.zh.md) -Python packages for driving DeepSeek Harness as a subprocess: a client SDK that spawns the `dsh-jsonrpc-agent` binary and talks newline-delimited JSON-RPC over stdio. The runtime carrier is the single-file executable produced by this repo; design, build, and acceptance details live in [.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). +Python packages for driving DeepSeek Harness as a subprocess. The client SDK communicates with the bundled runtime over newline-delimited JSON-RPC on stdio. ## Packages | Directory | Dist / module | Role | |---|---|---| -| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | Client SDK: the `DeepSeekHarness` high-level turns API and the lower-level `HarnessClient` JSON-RPC client | -| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Runtime carrier: locates the bundled runtime binaries and ships the default agent configuration | +| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | +| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | -## Building the runtime executable +## Behavior -The platform executables are build artifacts, not checked into git. From the repo root: +The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client owns channel selection and default-configuration injection; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete resolution and configuration contracts. -```sh -pnpm install -pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 -``` +## Contributor workflows -Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-<platform>-<arch>` (platform: `linux`/`macos`; arch: `x64`/`arm64`); macOS builds also sync the matching `-spawn-helper` required by `node-pty`. After a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). - -## Validating the SDK against the executable - -```sh -export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ -uv sync --project python/sdk --group test -uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers -uv run --project python/sdk pytest # full suite; keyless tests included -``` - -For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the repo-root `.env`): - -```python -from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: - print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe -``` - -## Running the SDK against the Node source (no executable) - -Two flavors, both for repo members: - -- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. -- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. - -## Distributing the Python packages - -The root [`package.json`](../package.json) version is authoritative for both Python distributions. The common staging script reads that version, injects it into both wheels, and pins the SDK metadata to the same `deepseek-harness-runtime-bin==X.Y.Z`; an optional `python-vX.Y.Z` release tag is accepted only when it matches the repository version. Build the pure SDK wheel once and one runtime wheel on each native platform: - -```sh -version="$(node -p "require('./package.json').version")" -python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" -``` - -The runtime distribution is wheel-only and rejects sdist builds, missing executables, and mixed-platform payloads. Its three wheel tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the SDK remains `py3-none-any`. A matching `python-vX.Y.Z` tag pipeline builds these four non-conflicting files and publishes them together, so a normal `pip install deepseek-harness==X.Y.Z` selects the matching runtime wheel and `import deepseek_harness` needs no `runtime_bin`. - -## Zero-config semantics - -The runtime binary itself always requires an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as the first argv argument), has no built-in fallback, and boots only what the config lists. Zero-config is SDK wrapper behavior: when the caller uses no explicit channel, the client injects the runtime package's checked-in default configuration ([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml)) via `DSH_CORDIS_CONFIG`; any explicit channel wins and disables the injection. The full injection conditions live in the [sdk README](sdk/README.md); the default config's contents and the hard semantic in the [sdk-runtime README](sdk-runtime/README.md). - -The executable is also a supported direct interface; keep stdin open for the NDJSON JSON-RPC exchange and supply a config explicitly: - -```sh -DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 -``` - -## Test layout - -`test_client.py` is fully keyless (a Python fake runtime is the peer). `test_bundled_runtime.py` boots each bundled carrier and skips per carrier when its artifact is missing. `test_runtime_resolution.py` covers the carrier-resolution rules without spawning anything. +The [Python contributor workflows](development.md) cover building runtime artifacts, validating the packages, source-mode development, and distribution. diff --git a/python/README.zh.md b/python/README.zh.md index b4b2b472e3..dd020aa507 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -2,75 +2,19 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK 启动 `dsh-jsonrpc-agent` 二进制,并通过 stdio 上按行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 +用于以子进程方式驱动 DeepSeek Harness 的 Python 包。客户端 SDK 通过 stdio 上按行分隔的 JSON-RPC 与内置运行时通信。 ## 包 | 目录 | 分发名 / 模块 | 职责 | |---|---|---| -| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | 客户端 SDK:高层轮次 API `DeepSeekHarness` 与低层 JSON-RPC 客户端 `HarnessClient` | -| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 运行时载体:定位内置的运行时二进制,并携带默认的 agent(智能体)配置 | +| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | -## 构建运行时可执行文件 +## 行为 -各平台可执行文件是构建产物,不检入 git。在仓库根目录执行: +除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端负责选择通道和注入默认配置;运行时本身始终要求显式配置。完整的解析与配置契约分别见 [SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)。 -```sh -pnpm install -pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 -``` +## 贡献者工作流 -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-<platform>-<arch>`(platform:`linux`/`macos`;arch:`x64`/`arm64`);macOS 构建还会同步 `node-pty` 所需的同名 `-spawn-helper` 伴随文件。本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 - -## 用可执行文件验证 SDK - -```sh -export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ -uv sync --project python/sdk --group test -uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers -uv run --project python/sdk pytest # full suite; keyless tests included -``` - -交互式验证(需要环境变量或仓库根 `.env` 中的 `DEEPSEEK_API_KEY`): - -```python -from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: - print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe -``` - -## 对着 Node 源码运行 SDK(不用可执行文件) - -两种方式,均面向仓库成员: - -- **已构建的 `node` 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`。这棵树每次运行构建脚本都会刷新,与 exe 打入 pkg 虚拟文件系统(VFS)的是同一份依赖闭包,因此插件语义一致。它不会被自动选中,也不进入分发物。 -- **未构建的源码(tsx)**——把客户端直接指向 `bin` 的 TypeScript 源码,用于编辑、运行和调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")`,`cwd` 设为仓库根,再通过 `cordis=...` 传入配置(或使用默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 - -## 分发 Python 包 - -根目录 [`package.json`](../package.json) 的版本是两个 Python 分发物的权威版本。统一暂存脚本读取这个版本并注入两个 wheel 包,同时在 SDK 元数据中钉死相同版本的 `deepseek-harness-runtime-bin==X.Y.Z`;可选的 `python-vX.Y.Z` 发布标签只有与仓库版本匹配时才会被接受。纯 SDK wheel 包只构建一次,运行时 wheel 包则在每个原生平台各构建一个: - -```sh -version="$(node -p "require('./package.json').version")" -python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" -``` - -运行时分发物只提供 wheel 包,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel 包标签分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。匹配的 `python-vX.Y.Z` 标签流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的运行时 wheel 包,`import deepseek_harness` 不需要 `runtime_bin`。 - -## 零配置语义 - -运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的内容。零配置是 SDK 包装层的行为:调用方没有使用任何显式通道时,客户端把运行时包中检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即优先采用,并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与强制语义见 [sdk-runtime README](sdk-runtime/README.md)。 - -可执行文件也支持直接调用;在 NDJSON JSON-RPC 交互期间保持 stdin 打开,并显式提供配置: - -```sh -DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 -``` - -## 测试布局 - -`test_client.py` 完全无需密钥(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某个载体产物缺失时跳过对应用例。`test_runtime_resolution.py` 覆盖载体解析规则,不启动任何进程。 +[Python 贡献者工作流](development.md)介绍运行时产物构建、包验证、源码模式开发和分发。 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml new file mode 100644 index 0000000000..b7792acf27 --- /dev/null +++ b/python/development.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 python/development.md +development.md: b0d4875f0d5b7c8fd2b4b480ac67793741640710 +development.zh.md: 4fda7efbeaa0359bd4999aca148ca21a63c4fd70 diff --git a/python/development.md b/python/development.md new file mode 100644 index 0000000000..b0d4875f0d --- /dev/null +++ b/python/development.md @@ -0,0 +1,61 @@ +# Python contributor workflows + +English | [中文](development.zh.md) + +Follow the workflow for the contributor outcome you need: build runtime artifacts, validate the SDK, run against source, or build distributions. Package behavior belongs in the [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md). + +## Build runtime artifacts + +Platform executables are build artifacts and are not checked into git. Run the build from the repository root: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts +``` + +Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` to select platforms. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. macOS builds also sync the matching spawn helper required by `node-pty`. + +## Validate the SDK + +Keep the virtual environment outside `python/`, install the test group, and run the Python suite: + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" +uv sync --project python/sdk --group test +uv run --project python/sdk pytest +``` + +`python/sdk/tests/test_bundled_runtime.py` exercises available bundled carriers and skips a carrier when its artifact has not been built. For repository-wide test policy, see [Testing](../docs/testing.md). + +An interactive smoke test needs `DEEPSEEK_API_KEY` in the environment or repository-root `.env`: + +```python +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) +``` + +## Run against Node source + +Repository contributors can select either development carrier: + +- Set `DSH_RUNTIME_MODE=node` to use the built Node carrier on system Node `>=22.19`. The build script refreshes this carrier, but distributions never include or auto-select it. +- Set `launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")` with the repository root as `cwd` to run unbuilt TypeScript source. Supply `cordis=...` when the default configuration is not suitable. + +See `python/sdk/tests/manual_sdk_agent_smoke.py` for a complete source-mode invocation. + +## Build distributions + +The root `package.json` version is authoritative for both Python distributions. The staging script injects that version into both wheels and pins the SDK to the same `deepseek-harness-runtime-bin` version. + +Build the pure SDK wheel once and one runtime wheel on each native platform: + +```sh +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness=="$version" +``` + +The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. diff --git a/python/development.zh.md b/python/development.zh.md new file mode 100644 index 0000000000..4fda7efbea --- /dev/null +++ b/python/development.zh.md @@ -0,0 +1,61 @@ +# Python 贡献者工作流 + +[English](development.md) | 中文 + +根据所需的贡献者成果选择工作流:构建运行时产物、验证 SDK、从源码运行或构建分发包。包行为分别见 [SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)。 + +## 构建运行时产物 + +各平台可执行文件是构建产物,不检入 git。请在仓库根目录运行构建: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts +``` + +所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。macOS 构建还会同步 `node-pty` 所需的配套 spawn helper。 + +## 验证 SDK + +请将虚拟环境放在 `python/` 之外,安装测试组,然后运行 Python 测试套件: + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" +uv sync --project python/sdk --group test +uv run --project python/sdk pytest +``` + +`python/sdk/tests/test_bundled_runtime.py` 会运行可用的内置载体;某个载体的产物尚未构建时,会跳过该载体。仓库级测试政策见[测试](../docs/testing.md)。 + +交互式冒烟测试需要环境变量或仓库根目录 `.env` 中存在 `DEEPSEEK_API_KEY`: + +```python +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) +``` + +## 针对 Node 源码运行 + +仓库贡献者可以选择以下任一开发载体: + +- 设置 `DSH_RUNTIME_MODE=node`,在系统 Node `>=22.19` 上使用已构建的 Node 载体。构建脚本会刷新该载体,但分发物绝不会包含或自动选择它。 +- 将仓库根目录设为 `cwd`,并设置 `launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")`,以运行未构建的 TypeScript 源码。默认配置不合适时,请提供 `cordis=...`。 + +完整的源码模式调用见 `python/sdk/tests/manual_sdk_agent_smoke.py`。 + +## 构建分发物 + +根目录 `package.json` 的版本是两个 Python 分发物的权威版本。暂存脚本会将该版本注入两个 wheel 包,并将 SDK 固定到同版本的 `deepseek-harness-runtime-bin`。 + +纯 SDK wheel 包只需构建一次;每个原生平台分别构建一个运行时 wheel 包: + +```sh +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness=="$version" +``` + +运行时分发物仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a4d555055d..4fd7291633 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index a96ccd30b8..7b3bd7895f 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: bb3420f1a1bd461facbd0eb1312a255df1da4412 -README.zh.md: adc8e377ca9f70838b6164301a9052e2247c8eff +README.md: 2d545688c58a2f1b755e647d7cda9555249e41c4 +README.zh.md: 653256584f06c0e63b725953f7f27bc91fc198cc diff --git a/python/sdk/README.md b/python/sdk/README.md index bb3420f1a1..2d545688c5 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -35,7 +35,9 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response. +`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. + +`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index adc8e377ca..653256584f 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -29,9 +29,11 @@ with DeepSeekHarness( result = harness.run("Make the requested code change.") ``` -`provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent 及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 +`provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。 +`Session.run()` 拥有一个从提示词的持久 inbox 回执开始、到整个 agent 下一次进入 idle 为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。 + +`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py index fab791d4f6..c15a6ed810 100644 --- a/python/sdk/src/deepseek_harness/__init__.py +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -1,4 +1,4 @@ -from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult +from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session from .client import HarnessClient, HarnessConfig from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo @@ -6,7 +6,7 @@ __all__ = [ "DeepSeekHarness", "DeepSeekHarnessConfig", "Session", - "TurnResult", + "RunResult", "HarnessClient", "HarnessConfig", "IncomingRequest", diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 1d5abdede4..22d1791bd3 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -35,9 +35,8 @@ class DeepSeekHarnessConfig: @dataclass(slots=True) -class TurnResult: +class RunResult: session_id: str - status: str final_response: str events: list[JsonObject] notifications: list[Notification] @@ -119,7 +118,7 @@ class DeepSeekHarness: *, session_id: str | None = None, on_notification: Callable[[Notification], None] | None = None, - ) -> TurnResult: + ) -> RunResult: return self.start_session(session_id).run(input, on_notification=on_notification) @@ -133,15 +132,12 @@ class Session: input: str | list[JsonObject], *, on_notification: Callable[[Notification], None] | None = None, - ) -> TurnResult: + ) -> RunResult: content_blocks = normalize_input(input) notifications: list[Notification] = [] events: list[JsonObject] = [] - status = "error" - finished = False def collect(notification: Notification) -> None: - nonlocal finished, status notifications.append(notification) if on_notification is not None: on_notification(notification) @@ -152,25 +148,31 @@ class Session: event = notification.payload.get("event") if isinstance(event, dict): events.append(event) - if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id: - status = str(notification.payload.get("status") or "ok") - finished = True with self.harness.client.subscribe_session_notifications(self.id) as subscription: - self.harness.client.session_prompt( + message_id = self.harness.client.session_prompt( self.id, content_blocks, - on_notification=collect, notification_subscription=subscription, ) - while not finished: + received = False + while True: notification = subscription.next() + if not received: + if not _is_inbox_receipt(notification, self.id, message_id): + continue + received = True collect(notification) + if ( + notification.method == "session.status" + and notification.payload.get("sessionId") == self.id + and notification.payload.get("status") == "idle" + ): + break - return TurnResult( + return RunResult( session_id=self.id, - status=status, final_response=final_response(events), events=events, notifications=notifications, @@ -178,6 +180,19 @@ class Session: ) +def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool: + if notification.method != "session.event" or notification.payload.get("sessionId") != session_id: + return False + event = notification.payload.get("event") + if not isinstance(event, dict) or event.get("type") != "agent/inbox/spliced": + return False + data = event.get("data") + inserted = data.get("inserted") if isinstance(data, dict) else None + return isinstance(inserted, list) and any( + isinstance(message, dict) and message.get("id") == message_id for message in inserted + ) + + def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: if isinstance(input, str): return [{"type": "text", "text": input}] diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 052969a694..629ddf901f 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -10,7 +10,7 @@ import uuid from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Callable, Literal, TypeAlias, TypeVar +from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel @@ -142,9 +142,9 @@ class HarnessClient: *, on_notification: Callable[[Notification], None] | None = None, notification_subscription: "NotificationSubscription | None" = None, - ) -> None: + ) -> str: payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} - self.request( + response = self.request( "session/prompt", payload, response_model=_SessionPromptResponse, @@ -152,6 +152,7 @@ class HarnessClient: notification_filter=self._notification_belongs_to_session_tree(session_id), notification_subscription=notification_subscription, ) + return response.messageId def request( self, @@ -536,7 +537,7 @@ class NotificationSubscription: class _SessionPromptResponse(BaseModel): - accepted: Literal[True] + messageId: str class _ShutdownResponse(BaseModel): diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index 751b7fc0bf..ae94f1bb07 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -73,9 +73,7 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: "Please reply with a short confirmation and do not call tools.", session_id="sdk-smoke-main", ) - print(f"turn_status={result.status}") print(f"final_response={result.final_response}") - assert result.status == "ok", result assert "configured HTTP model endpoint" in result.final_response assert len(MockCompletionHandler.requests) == 1 request = MockCompletionHandler.requests[0] diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 576486c704..7f9316ded6 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -39,6 +39,9 @@ for line in sys.stdin: print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) elif method == "session/prompt": params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({ "jsonrpc": "2.0", "method": "session.event", @@ -57,10 +60,9 @@ for line in sys.stdin: }), flush=True) print(json.dumps({ "jsonrpc": "2.0", - "method": "session.finished", - "params": {"sessionId": params["sessionId"], "status": "ok"}, + "method": "session.status", + "params": {"sessionId": params["sessionId"], "status": "idle"}, }), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -83,9 +85,8 @@ for line in sys.stdin: ) as harness: result = harness.run("say hello", session_id="main") - assert result.status == "ok" assert result.final_response == "hello from runtime" - assert result.events[0]["type"] == "assistant/message" + assert result.events[-1]["type"] == "assistant/message" dumped_env = json.loads(env_dump.read_text()) assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" @@ -113,9 +114,11 @@ for line in sys.stdin: if method == "initialize": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -133,8 +136,7 @@ for line in sys.stdin: on_notification=lambda notification: seen.append(notification.method), ) - assert result.status == "ok" - assert seen == ["subagent.started", "session.finished"] + assert seen == ["session.event", "session.status", "subagent.started", "session.status"] def test_relative_cwd_is_absolute_in_process_environment_and_wire( @@ -189,10 +191,12 @@ for line in sys.stdin: if method == "initialize": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -205,11 +209,12 @@ for line in sys.stdin: ) as harness: result = harness.run("spawn a helper", session_id="main") - assert result.status == "ok" assert [notification.method for notification in result.notifications] == [ + "session.event", + "session.status", "subagent.started", "subagent.finished", - "session.finished", + "session.status", ] @@ -229,6 +234,9 @@ for line in sys.stdin: print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) elif method == "session/prompt": root = (msg.get("params") or {})["sessionId"] + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": root, "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True) @@ -236,8 +244,7 @@ for line in sys.stdin: print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": root, "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -256,10 +263,11 @@ for line in sys.stdin: ) assert harness.client._notifications.qsize() == 0 - assert result.status == "ok" assert result.final_response == "root response" - assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"] + assert [event["data"]["content"][0]["text"] for event in result.events if event["type"] == "assistant/message"] == ["root response"] assert [notification.method for notification in result.notifications] == [ + "session.event", + "session.status", "subagent.started", "session.event", "subagent.started", @@ -267,7 +275,7 @@ for line in sys.stdin: "subagent.finished", "subagent.finished", "session.event", - "session.finished", + "session.status", ] assert seen == [notification.method for notification in result.notifications] @@ -287,10 +295,12 @@ for line in sys.stdin: elif method == "session/prompt": params = msg.get("params") or {} print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "other", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "other", "status": "idle"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -303,9 +313,8 @@ for line in sys.stdin: ) as harness: result = harness.run("stay in your lane", session_id="main") - assert result.status == "ok" assert result.final_response == "right session" - assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main", "main"] + assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main"] * 4 def test_high_level_session_run_does_not_accumulate_global_notifications(tmp_path: Path) -> None: @@ -322,9 +331,11 @@ for line in sys.stdin: print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) elif method == "session/prompt": params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -333,11 +344,10 @@ for line in sys.stdin: with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: result = harness.run("one turn", session_id="main") - assert result.status == "ok" assert harness.client._notifications.qsize() == 0 -def test_session_run_waits_for_late_finished_without_replaying_stale_notifications(tmp_path: Path) -> None: +def test_session_run_waits_for_late_idle_without_replaying_stale_notifications(tmp_path: Path) -> None: script = tmp_path / "fake_runtime.py" script.write_text( """ @@ -355,15 +365,17 @@ for line in sys.stdin: turn += 1 params = msg.get("params") or {} session_id = params["sessionId"] + message_id = f"message-{turn}" + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": message_id}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "running"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": message_id}}), flush=True) if turn == 1: print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "idle"}}), flush=True) else: - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) time.sleep(0.05) print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "second"}]}}}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "idle"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -376,7 +388,7 @@ for line in sys.stdin: assert first.final_response == "first" assert second.final_response == "second" - assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main", "main"] + assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main"] * 4 def test_client_starts_subprocess_sends_requests_and_routes_notifications(tmp_path: Path) -> None: @@ -394,7 +406,7 @@ for line in sys.stdin: elif method == "session/prompt": params = msg.get("params") or {} print(json.dumps({"jsonrpc": "2.0", "method": "llm/request", "params": {"requestId": "req-1", "sessionId": params["sessionId"], "model": "dsagent", "messages": []}}), flush=True) - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break @@ -531,7 +543,7 @@ for line in sys.stdin: elif method in {"emit-first", "emit-second"}: print(json.dumps({"jsonrpc": "2.0", "method": "tick", "params": {"source": method}}), flush=True) elif method == "session/prompt": - print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) elif method == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) break diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a664ca988..6bd613c2ea 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,6 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' +import { isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages/<group>/<pkg> nests one level deeper @@ -14,6 +15,7 @@ const root = resolve(import.meta.dirname, '..') const workspaceGlobs = [ { dir: 'vendor', depth: 1 }, { dir: 'packages', depth: 2 }, + { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ 'cordis', @@ -28,6 +30,10 @@ const vendoredPackages = new Set([ ]) const localArtifactDirs = new Set(['node_modules']) +const appPackageFiles: Readonly<Record<string, readonly string[]>> = { + '@deepseek-ai/dsh': ['lib/*.js', 'config'], + '@deepseek-ai/dsh-frontend': ['dist'], +} /** The subset of package.json fields this constraint check cares about. */ interface PackageManifest { @@ -96,7 +102,9 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly<Record<string, readonly string[]>> = { + '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', @@ -133,8 +141,6 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', ] } @@ -164,7 +170,24 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { return errors } - if (manifest.name?.startsWith('@deepseek-ai/dsh-') && manifest.name !== '@deepseek-ai/dsh-root') { + if (manifest.name?.startsWith('@deepseek-ai/')) { + for (const file of manifest.files ?? []) { + if (isForbiddenPublicationFile(file)) { + errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) + } + } + } + + if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) { + const expectedFiles = appPackageFiles[manifest.name] + if (expectedFiles === undefined) { + errors.push(`${label}: app package has no publication files policy`) + } else if (!sameStringList(manifest.files, expectedFiles)) { + errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) + } + } + + if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { const peer = manifest.peerDependencies?.cordis const dev = manifest.devDependencies?.cordis diff --git a/scripts/coverage-uncovered-locations.cjs b/scripts/coverage-uncovered-locations.cjs new file mode 100644 index 0000000000..ba32fb7692 --- /dev/null +++ b/scripts/coverage-uncovered-locations.cjs @@ -0,0 +1,108 @@ +'use strict'; + +/** + * Istanbul coverage reporter printing one clickable `path:line:col` record per + * uncovered statement, branch path, and function. Vitest's per-file threshold + * failures name only the file; this reporter supplies the exact locations, + * printed just above those ERROR lines (reports run before threshold checks). + * Files at 100% print nothing, so a green run stays silent. + * + * CommonJS by requirement: istanbul-reports loads custom reporters with a bare + * require() outside the tsx/ESM pipeline (istanbul-reports index.js create()), + * so this file can be neither TypeScript nor ESM. Wired into vitest.config.ts + * by absolute path — require() would resolve a relative specifier against + * istanbul-reports' own directory. + */ + +const path = require('node:path'); +const { ReportBase } = require('istanbul-lib-report'); + +/** + * Editor-convention `line:column` of an istanbul location start (istanbul + * columns are 0-based; editors and terminal link handlers expect 1-based). + */ +function pos(loc) { + return `${loc.start.line}:${loc.start.column + 1}`; +} + +/** Whether a location carries a usable 1-based start line. */ +function usable(loc) { + return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1); +} + +/** + * ` (to line:col)` suffix when the range end adds information beyond the + * start. v8-remapped whole-line statements carry end.column = Infinity; those + * degrade to a line-only suffix, or to nothing on a single line. + */ +function endSuffix(loc) { + const end = loc.end; + if (!end || !Number.isFinite(end.line) || end.line < 1) return ''; + if (!Number.isFinite(end.column)) { + return end.line === loc.start.line ? '' : ` (to ${end.line})`; + } + if (end.line === loc.start.line && end.column === loc.start.column) return ''; + return ` (to ${end.line}:${end.column + 1})`; +} + +class UncoveredLocationsReport extends ReportBase { + constructor(opts = {}) { + super(opts); + // Vitest passes the resolved config root alongside reporter options. + this.projectRoot = opts.projectRoot || process.cwd(); + this.records = []; + } + + onStart() { + this.records = []; + } + + onDetail(node) { + const fc = node.getFileCoverage(); + const rel = path.relative(this.projectRoot, fc.path).split(path.sep).join('/'); + const items = []; + const add = (loc, text) => items.push({ line: loc.start.line, column: loc.start.column, text }); + + for (const id of Object.keys(fc.statementMap)) { + if (fc.s[id] !== 0) continue; + const loc = fc.statementMap[id]; + if (!usable(loc)) continue; + add(loc, `${rel}:${pos(loc)} uncovered statement${endSuffix(loc)}`); + } + + for (const id of Object.keys(fc.fnMap)) { + if (fc.f[id] !== 0) continue; + const fn = fc.fnMap[id]; + const loc = usable(fn.decl) ? fn.decl : fn.loc; + if (!usable(loc)) continue; + const name = fn.name ? ` ${fn.name}` : ''; + add(loc, `${rel}:${pos(loc)} uncovered function${name}`); + } + + for (const id of Object.keys(fc.branchMap)) { + const counts = fc.b[id]; + const branch = fc.branchMap[id]; + for (let i = 0; i < counts.length; i += 1) { + if (counts[i] !== 0) continue; + // Implicit arms (e.g. a missing else) may carry an empty location; + // fall back to the branch's own span so the record stays clickable. + const loc = usable(branch.locations && branch.locations[i]) ? branch.locations[i] : branch.loc; + if (!usable(loc)) continue; + add(loc, `${rel}:${pos(loc)} uncovered branch (${branch.type}, path ${i + 1}/${counts.length})`); + } + } + + if (items.length === 0) return; + items.sort((a, b) => a.line - b.line || a.column - b.column); + for (const item of items) this.records.push(item.text); + } + + onEnd() { + if (this.records.length === 0) return; + console.log(`\nUncovered locations (per-file 100% gate): ${this.records.length}`); + for (const record of this.records) console.log(record); + console.log(''); + } +} + +module.exports = UncoveredLocationsReport; diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 5c88ab3fc2..a6ad066add 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,10 +1,10 @@ { "AGENTS.md": 1775, - "docs/AGENTS.md": 1150, + "docs/AGENTS.md": 1320, "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1100, + "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, "packages/README.md": 920 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bdbd3a0ceb..57c3d203d4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -28,8 +28,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', - InboxItem: 'core.md', - InboxPlacement: 'core.md', MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', @@ -46,9 +44,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = { Message: 'core.md', MessageSource: 'core.md', UserMessage: 'session.md', - PromptDecision: 'core.md', - RequestError: 'core.md', + PreStepDecision: 'core.md', + PreStepContext: 'core.md', RequestErrorAction: 'core.md', + RequestFailureContext: 'core.md', PreparedReferencedMessage: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', @@ -103,8 +102,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = { StreamChunk: 'llm-streaming.md', SkillProviderControl: 'skills.md', CreateSessionOptions: 'persistence.md', + PrepareSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', + SessionInspection: 'persistence.md', SessionLocation: 'persistence.md', + SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', @@ -277,6 +279,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = { LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts', WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts', + WebUpgradeRoute: + 'upgrade route registration contract is owned by packages/host/webserver/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 36677e120e..1dd7973fd5 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -328,16 +328,17 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'bash', title: 'Bash executor seam', mode: 'seam', - implementations: ['bash-local', 'bash-sandbox'], - consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], - note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.', + implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'], + consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude', 'hooks-codex'], + note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.', }, { key: 'bashEnv', - pkg: 'tool-bash', + pkg: 'bash-env', title: 'Managed bash environment registry', mode: 'core', - note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', + consumers: ['tool-bash', 'tool-pwsh'], + note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.', }, { key: 'pty', @@ -1152,20 +1153,22 @@ function renderLifecycle(): string { ' participant Session', ' participant SDK as UI or SDK listener', ' User->>Agent: followup(content)', - ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, + ` Agent-->>SDK: ${mermaidCode('agent/inbox/spliced')}`, + ` Agent-->>SDK: ${mermaidCode('agent/inbox/inserted')} { message }`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, - ' Note over Agent,Driver: next-step acceptance window opens', - ` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`, - ' Hooks-->>Driver: authoritative allow, block, or add context', - ' alt prompt blocked or admission failed', - ' Driver-->>Driver: append context-only batch or keep steering boundary pending', - ' else prompt allowed', + ' Note over Agent,Driver: claim pending next-step input plus one queued prompt', + ` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`, + ` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`, + ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`, + ' Hooks-->>Driver: authoritative reject or enter(messages)', + ' alt proposed step rejected or pre-step failed', + ' Driver-->>Driver: claimed batch stays removed, no turn opens', + ' else enter proposed step', ` Driver->>Session: ${mermaidCode('turn/start')}`, - ` Driver->>Session: ${mermaidCode('user/message')}`, - ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`, - ` Driver-->>Driver: ${mermaidCode('agent/step')} serial checkpoint`, ` Driver->>Session: ${mermaidCode('step/start')}`, + ` Driver->>Session: ${mermaidCode('user/message')} per entered message`, + ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`, ` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`, ' LLM-->>Driver: StreamChunk*', ` Driver->>Session: ${mermaidCode('assistant/chunk')}*`, @@ -1188,11 +1191,17 @@ function renderLifecycle(): string { ` Driver->>Session: ${mermaidCode('tool/result')}`, ' end', ' end', - ' Driver->>Session: post-tool context and steering (no prompt-submit)', ` Driver->>Session: ${mermaidCode('step/end')}`, - ` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`, + ' opt natural stop and next-step inbox empty', + ` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`, + ' end', + ' opt next-step input is pending', + ' Driver-->>Driver: claim pending next-step input', + ` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`, + ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`, + ' Hooks-->>Driver: authoritative reject or enter(messages)', + ' end', ' end', - ' Note over Agent,Driver: next-step acceptance window closes', ` Driver->>Session: ${mermaidCode('turn/end')}`, ' end', ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`, @@ -1200,9 +1209,9 @@ function renderLifecycle(): string { '', 'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.', '', - '`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', + '`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', - 'The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint.', + 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.', '', 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', '', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 6b790829d5..0d41953e4b 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -562,7 +562,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://' ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. ${renderNpmTable(runtimeDeps)} diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 1d214b6e8e..d19b42bc8e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,6 +19,8 @@ import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -36,6 +38,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -190,16 +193,35 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', source: 'packages/bash/tool-bash/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, note: 'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh', + dir: 'tool-pwsh', + source: 'packages/bash/tool-pwsh/src/index.ts', + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The pwsh tool consumes the bash executor seam; the schema harvest + // mounts the pwsh-local implementation so the inject resolves without + // executing anything (registration never spawns a process). + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PwshLocalExecutor) + await ctx.plugin(ToolPwsh) + }, + note: + 'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.', + }, { pkg: '@deepseek-ai/dsh-tool-cordis', dir: 'tool-cordis', @@ -289,7 +311,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-goal', source: 'packages/goal/tool-goal/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], - writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'], + writes: ['tool/call', 'goal/change for mutations', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index ba4695f4d4..0f840de9c8 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -14,6 +14,7 @@ import { } from 'node:fs' import { spawnSync } from 'node:child_process' import { dirname, isAbsolute, join, resolve } from 'node:path' +import lefthookPackage from 'lefthook/package.json' with { type: 'json' } const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' @@ -596,6 +597,7 @@ function refuseScopedHooksPath(entry) { async function main() { if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return + if (typeof lefthookPackage.bin?.lefthook !== 'string') return const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return const root = stripGitLineTerminator(probe.stdout) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 0a116dea86..ca74b91404 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -19,6 +19,9 @@ import { afterEach, describe, expect, it } from 'vitest' const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) const fixtures: string[] = [] +// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can +// legitimately exceed Vitest's default deadline without changing the installer behavior. +const MULTI_PROCESS_TEST_TIMEOUT_MS = 20_000 interface Fixture { container: string @@ -260,7 +263,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') - }) + }, MULTI_PROCESS_TEST_TIMEOUT_MS) it('replaces the owned hook path Git copies into a newly added worktree', async () => { const fixture = createFixture() @@ -284,7 +287,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { '# config=late-linked-worktree-config', ) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore) - }) + }, MULTI_PROCESS_TEST_TIMEOUT_MS) it('serializes concurrent installs and keeps repeated output stable', async () => { const fixture = createFixture() @@ -305,7 +308,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) - }) + }, MULTI_PROCESS_TEST_TIMEOUT_MS) it('waits for a concurrent installer to finish publishing its lock record', async () => { const fixture = createFixture() @@ -343,7 +346,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain( JSON.stringify(movedHooks), ) - }) + }, MULTI_PROCESS_TEST_TIMEOUT_MS) it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => { const fixture = createFixture() @@ -384,7 +387,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(result.stderr).toContain('non-regular or multiply linked hook entry') expect(readFileSync(externalHook, 'utf8')).toBe(externalContent) } - }) + }, MULTI_PROCESS_TEST_TIMEOUT_MS) it('restores the marker-backed stale hook path when relocation reinstall fails', async () => { const fixture = createFixture() diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 787202c9d1..32705c4c87 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -47,7 +47,7 @@ function fixture(options: { default: './lib/invariant.js', }, }, - files: ['lib/index.js', 'lib/invariant.js', 'src'], + files: ['lib/index.js', 'lib/invariant.js'], peerDependencies: options.invariantDependency === false ? {} : { '@deepseek-ai/dsh-invariants': '^0.0.1', }, diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts new file mode 100644 index 0000000000..0f403bee7c --- /dev/null +++ b/scripts/publication-payload.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' + +function validateFixtureTarball(files: readonly string[]): () => void { + return () => { + validateTarballPayload(files, 'fixture.tgz') + } +} + +describe('publication payload policy', () => { + it.each([ + 'lib/index.js', + 'lib/types/index.d.ts', + 'lib/styles/base.css', + ])('accepts %s', (file) => { + expect(isForbiddenPublicationFile(file)).toBe(false) + }) + + it.each([ + 'src', + './src', + 'src/', + 'src/index.ts', + './src/index.ts', + String.raw`src\index.ts`, + 'lib/types/index.d.ts.map', + './lib/types/index.d.ts.map', + ])('rejects static manifest path %s', (file) => { + expect(isForbiddenPublicationFile(file)).toBe(true) + }) + + it('rejects source members in packed tarballs', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/src/index.ts', + ])).toThrow('fixture.tgz publishes source file package/src/index.ts') + }) + + it('rejects declaration maps in packed tarballs', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/types/index.d.ts.map', + ])).toThrow('fixture.tgz publishes declaration map package/lib/types/index.d.ts.map') + }) + + it('accepts a clean packed tarball', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/index.js', + 'package/lib/types/index.d.ts', + 'package/lib/styles/base.css', + ])).not.toThrow() + }) +}) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts new file mode 100644 index 0000000000..9c16067fe7 --- /dev/null +++ b/scripts/publication-payload.ts @@ -0,0 +1,27 @@ +/** Publication payload policy shared by static manifests and packed tarballs. */ + +/** Normalize a package manifest path or npm tarball member to its payload-relative path. */ +function payloadPath(file: string): string { + const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') + return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized +} + +/** Whether a package payload path exposes source or declaration-map intermediates. */ +export function isForbiddenPublicationFile(file: string): boolean { + const normalized = payloadPath(file) + return normalized === 'src' + || normalized.startsWith('src/') + || normalized.endsWith('.d.ts.map') +} + +/** Reject source and declaration-map members in a packed npm tarball. */ +export function validateTarballPayload(files: readonly string[], context: string): void { + for (const file of files) { + if (!isForbiddenPublicationFile(file)) continue + const normalized = payloadPath(file) + if (normalized === 'src' || normalized.startsWith('src/')) { + throw new Error(`${context} publishes source file ${file}`) + } + throw new Error(`${context} publishes declaration map ${file}`) + } +} diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts new file mode 100644 index 0000000000..92df711f3e --- /dev/null +++ b/scripts/publish-npm-baseline.ts @@ -0,0 +1,1084 @@ +/** Build, publish, and verify one commit-addressed npm workspace baseline. */ + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path' +import { createInterface } from 'node:readline/promises' +import { pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' +import { validateTarballPayload } from './publication-payload.ts' + +const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' +const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' +const PACKAGE_PATTERNS = [ + 'vendor/*/package.json', + 'packages/*/*/package.json', + 'apps/*/package.json', +] as const +const DEPENDENCY_SECTIONS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const +const RELEASE_MANIFEST_NAME = 'manifest.json' +const RELEASE_ENTRY_PACKAGE = '@deepseek-ai/dsh' +const LATEST_DIST_TAG = 'latest' +const POSIX_WEB_PROBE = String.raw` +import errno, os, pty, select, signal, sys, time +node, bin_path, cwd, timeout_seconds = sys.argv[1:] +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, bin_path, "web", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) + +output = bytearray() +ready_seen = False +termination_sent = False +deadline = time.monotonic() + float(timeout_seconds) +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + + snapshot = bytes(output) + if not termination_sent and b"dsh web: http://127.0.0.1:" in snapshot: + ready_seen = True + os.kill(pid, signal.SIGTERM) + termination_sent = True + + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if not ready_seen: + sys.stderr.write("installed dsh web did not reach its ready URL\n") + sys.exit(124) +actual_exit = os.waitstatus_to_exitcode(status) +if actual_exit != 0: + sys.stderr.write(f"installed dsh web exited {actual_exit}, expected 0\n") + sys.exit(125) +` + +interface CommandResult { + status: number + stdout: string + stderr: string +} + +interface PackageTarget { + name: string + directory: string + origin: PackageOrigin +} + +type PackageOrigin = 'harness' | 'vendor' + +interface PackedPackage { + name: string + tarball: string + sha256: string + integrity: string + origin: PackageOrigin +} + +interface ReleaseManifest { + schemaVersion: 1 + commit: string + version: string + distTag: string + registry: string + packages: PackedPackage[] +} + +interface PackOptions { + ref: string + registry: string + outputDirectory: string +} + +/** Fixes the identity of one pack attempt before any expensive work begins. */ +class BaselinePackPlan { + constructor( + readonly commit: string, + readonly shortCommit: string, + readonly timestamp: string, + readonly baseVersion: string, + readonly version: string, + readonly distTag: string, + readonly registry: string, + readonly artifactDirectory: string, + ) {} + + async confirm(assumeYes: boolean): Promise<void> { + console.log('publish-npm-baseline: planned pack') + console.log(` commit: ${this.commit}`) + console.log(` timestamp: ${this.timestamp} UTC`) + console.log(` version: ${this.version}`) + console.log(` dist-tag: ${this.distTag}`) + console.log(` registry: ${this.registry}`) + console.log(` output: ${this.artifactDirectory}`) + if (assumeYes) return + await confirmEnter( + 'Press Enter to start packing or type anything to cancel: ', + 'pack requires an interactive terminal or --yes', + 'pack cancelled', + ) + } +} + +/** Runs child processes without involving a command shell. */ +class CommandRunner { + run( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): void { + const result = spawnSync(command, args, { cwd, env: environment, stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`${formatCommand(command, args)} exited with status ${String(result.status)}`) + } + } + + capture( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): string { + const result = this.result(command, args, cwd, environment) + if (result.status !== 0) throw commandFailure(command, args, result) + return result.stdout.trim() + } + + result( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): CommandResult { + const result: SpawnSyncReturns<string> = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: environment, + maxBuffer: 16 * 1024 * 1024, + }) + if (result.error !== undefined) throw result.error + return { + status: result.status ?? 1, + stdout: result.stdout, + stderr: result.stderr, + } + } +} + +/** Owns a temporary detached worktree and removes it after staging. */ +class DetachedWorktree { + private constructor( + readonly path: string, + private readonly temporaryRoot: string, + private readonly repositoryRoot: string, + private readonly runner: CommandRunner, + ) {} + + static create(repositoryRoot: string, commit: string, runner: CommandRunner): DetachedWorktree { + const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-baseline-')) + const path = join(temporaryRoot, 'worktree') + try { + runner.run('git', ['worktree', 'add', '--detach', path, commit], repositoryRoot) + return new DetachedWorktree(path, temporaryRoot, repositoryRoot, runner) + } catch (error: unknown) { + rmSync(temporaryRoot, { recursive: true, force: true }) + throw error + } + } + + dispose(): void { + const result = this.runner.result( + 'git', + ['worktree', 'remove', '--force', this.path], + this.repositoryRoot, + ) + if (result.status !== 0) { + console.error(`publish-npm-baseline: could not remove worktree ${this.path}`) + if (result.stderr.trim() !== '') console.error(result.stderr.trim()) + } + rmSync(this.temporaryRoot, { recursive: true, force: true }) + } +} + +/** Discovers and stages every package published in one repository baseline. */ +class WorkspacePackageSet { + private constructor( + readonly packages: PackageTarget[], + readonly baseVersion: string, + ) {} + + static discover(root: string): WorkspacePackageSet { + const manifestPaths = globSync(PACKAGE_PATTERNS, { cwd: root }).sort() + if (manifestPaths.length === 0) { + throw new Error('no package manifests found under vendor/, packages/, or apps/') + } + + const packages: PackageTarget[] = [] + const names = new Set<string>() + const baseVersion = expectString(readObject(resolve(root, 'package.json')), 'version', 'package.json') + if (!/^\d+\.\d+\.\d+$/.test(baseVersion)) { + throw new Error(`package.json must have a stable X.Y.Z version, got ${baseVersion}`) + } + for (const manifestPath of manifestPaths) { + const manifest = readObject(resolve(root, manifestPath)) + const name = expectString(manifest, 'name', manifestPath) + const version = expectString(manifest, 'version', manifestPath) + const isVendored = manifestPath.startsWith('vendor/') + if (!isVendored && !name.startsWith('@deepseek-ai/')) { + throw new Error(`${manifestPath} must name an @deepseek-ai package`) + } + if (name === '@deepseek-ai/dsh-root') { + throw new Error(`${manifestPath} unexpectedly selected the workspace root`) + } + if (names.has(name)) throw new Error(`duplicate package name: ${name}`) + if (!isVendored && version !== baseVersion) { + throw new Error(`${manifestPath} has version ${version}; expected ${baseVersion}`) + } + names.add(name) + packages.push({ + name, + directory: dirname(manifestPath), + origin: isVendored ? 'vendor' : 'harness', + }) + } + packages.sort((left, right) => left.name.localeCompare(right.name)) + return new WorkspacePackageSet(packages, baseVersion) + } + + stage(root: string, releaseVersion: string): void { + const internalNames = new Set(this.packages.map(pkg => pkg.name)) + for (const target of this.packages) { + const manifestPath = resolve(root, target.directory, 'package.json') + const manifest = readObject(manifestPath) + manifest.version = releaseVersion + delete manifest.private + stageInternalDependencies(manifest, internalNames, releaseVersion, manifestPath) + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + } + } +} + +/** Immutable local release bundle consumed by publish and verify. */ +class ReleaseBundle { + private constructor( + readonly directory: string, + readonly manifest: ReleaseManifest, + ) {} + + static create( + directory: string, + expectedPackages: PackageTarget[], + commit: string, + version: string, + distTag: string, + registry: string, + runner: CommandRunner, + ): ReleaseBundle { + const internalNames = new Set(expectedPackages.map(pkg => pkg.name)) + const expectedByName = new Map(expectedPackages.map(pkg => [pkg.name, pkg])) + const missingNames = new Set(internalNames) + const packages = readdirSync(directory) + .filter(name => name.endsWith('.tgz')) + .sort() + .map((tarball) => { + const artifact = inspectTarball(resolve(directory, tarball), runner) + const expected = expectedByName.get(artifact.name) + if (expected === undefined || !missingNames.delete(artifact.name)) { + throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) + } + if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (artifact.version !== version) { + throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) + } + if (artifact.private === true) throw new Error(`${tarball} is still private`) + if (containsWorkspaceProtocol(artifact.manifest)) { + throw new Error(`${tarball} still contains a workspace: dependency`) + } + validateInternalDependencyPins(artifact.manifest, internalNames, version, tarball) + return packedPackage(artifact.name, resolve(directory, tarball), expected.origin) + }) + .sort((left, right) => left.name.localeCompare(right.name)) + + if (missingNames.size !== 0) { + throw new Error(`missing tarballs for: ${[...missingNames].sort().join(', ')}`) + } + const manifest: ReleaseManifest = { + schemaVersion: 1, + commit, + version, + distTag, + registry, + packages, + } + writeFileSync(resolve(directory, RELEASE_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync( + resolve(directory, 'SHA256SUMS'), + `${packages.map(pkg => `${pkg.sha256} ${pkg.tarball}`).join('\n')}\n`, + ) + return new ReleaseBundle(directory, manifest) + } + + static load(manifestPath: string, runner: CommandRunner): ReleaseBundle { + const absoluteManifestPath = resolve(manifestPath) + const raw = readObject(absoluteManifestPath) + if (raw.schemaVersion !== 1) { + throw new Error(`unsupported release manifest schema: ${String(raw.schemaVersion)}`) + } + const directory = dirname(absoluteManifestPath) + const packageValues = raw.packages + if (!Array.isArray(packageValues) || packageValues.length === 0) { + throw new Error('release manifest contains no packages') + } + const packages = packageValues.map((value, index) => parsePackedPackage(value, index)) + const names = new Set<string>() + for (const pkg of packages) { + if (names.has(pkg.name)) throw new Error(`duplicate package in release manifest: ${pkg.name}`) + names.add(pkg.name) + } + const manifest: ReleaseManifest = { + schemaVersion: 1, + commit: expectString(raw, 'commit', RELEASE_MANIFEST_NAME), + version: expectString(raw, 'version', RELEASE_MANIFEST_NAME), + distTag: expectString(raw, 'distTag', RELEASE_MANIFEST_NAME), + registry: normalizeRegistry(expectString(raw, 'registry', RELEASE_MANIFEST_NAME)), + packages, + } + const bundle = new ReleaseBundle(directory, manifest) + bundle.verifyLocal(runner) + return bundle + } + + private verifyLocal(runner: CommandRunner): void { + const internalNames = new Set(this.manifest.packages.map(pkg => pkg.name)) + for (const pkg of this.manifest.packages) { + if (isAbsolute(pkg.tarball) || dirname(pkg.tarball) !== '.' || normalize(pkg.tarball) !== pkg.tarball) { + throw new Error(`invalid tarball path for ${pkg.name}: ${pkg.tarball}`) + } + const path = resolve(this.directory, pkg.tarball) + const actual = packedPackage(pkg.name, path, pkg.origin) + if (actual.sha256 !== pkg.sha256 || actual.integrity !== pkg.integrity) { + throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) + } + const artifact = inspectTarball(path, runner) + if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { + throw new Error(`tarball identity mismatch: ${pkg.tarball}`) + } + if (artifact.private === true) throw new Error(`${pkg.tarball} is still private`) + if (containsWorkspaceProtocol(artifact.manifest)) { + throw new Error(`${pkg.tarball} still contains a workspace: dependency`) + } + validateInternalDependencyPins( + artifact.manifest, + internalNames, + this.manifest.version, + pkg.tarball, + ) + } + } + + tarballPath(pkg: PackedPackage): string { + return resolve(this.directory, pkg.tarball) + } +} + +/** Installs one complete bundle outside the workspace and probes the shipped dsh entry. */ +class InstalledBundleSmoke { + constructor( + private readonly bundle: ReleaseBundle, + private readonly runner: CommandRunner, + ) {} + + run(): void { + const consumerRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-consumer-')) + try { + const dependencies = Object.fromEntries(this.bundle.manifest.packages.map(pkg => [ + pkg.name, + pathToFileURL(this.bundle.tarballPath(pkg)).href, + ])) + writeFileSync(resolve(consumerRoot, 'package.json'), `${JSON.stringify({ + name: 'dsh-npm-baseline-consumer', + version: '0.0.0', + private: true, + dependencies, + }, null, 2)}\n`) + + console.log( + `publish-npm-baseline: installing ${this.bundle.manifest.packages.length} local tarballs`, + ) + this.runner.run('npm', [ + 'install', + '--no-audit', + '--no-fund', + '--package-lock=false', + `--registry=${this.bundle.manifest.registry}`, + ], consumerRoot, npmClientEnvironment()) + + const bin = resolve(consumerRoot, 'node_modules/@deepseek-ai/dsh/lib/bin.js') + assertPathWithin(consumerRoot, bin, 'installed dsh bin') + const environment = installedArtifactEnvironment(consumerRoot) + const version = this.runner.capture( + process.execPath, + [bin, '--version'], + consumerRoot, + environment, + ) + if (version !== this.bundle.manifest.version) { + throw new Error( + `installed dsh --version returned ${JSON.stringify(version)}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + const config = this.runner.capture( + process.execPath, + [bin, '--dump-default-config'], + consumerRoot, + environment, + ) + if (config === '') throw new Error('installed dsh --dump-default-config returned no output') + this.probeWeb(bin, consumerRoot, environment) + console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed') + } finally { + rmSync(consumerRoot, { recursive: true, force: true }) + } + } + + private probeWeb(bin: string, consumerRoot: string, environment: NodeJS.ProcessEnv): void { + if (process.platform === 'win32') { + throw new Error('installed dsh Web probe requires a POSIX host with python3') + } + const result = this.runner.result( + 'python3', + ['-c', POSIX_WEB_PROBE, process.execPath, bin, consumerRoot, '60'], + consumerRoot, + environment, + ) + if (result.status !== 0) { + throw commandFailure('python3', ['installed-dsh-web-probe'], result) + } + } +} + +/** Builds a release bundle without mutating the caller's checkout. */ +class BaselinePackager { + constructor( + private readonly repositoryRoot: string, + private readonly runner: CommandRunner, + private readonly now: () => Date = () => new Date(), + ) {} + + plan(options: PackOptions): BaselinePackPlan { + const timestamp = formatUtcTimestamp(this.now()) + const registry = normalizeRegistry(options.registry) + const commit = this.runner.capture( + 'git', + ['rev-parse', '--verify', `${options.ref}^{commit}`], + this.repositoryRoot, + ) + const shortCommit = this.runner.capture( + 'git', + ['rev-parse', '--short=10', commit], + this.repositoryRoot, + ) + const rootManifest = parseObject( + this.runner.capture('git', ['show', `${commit}:package.json`], this.repositoryRoot), + `${commit}:package.json`, + ) + const baseVersion = expectString(rootManifest, 'version', `${commit}:package.json`) + validateBaseVersion(baseVersion, `${commit}:package.json`) + const version = `${baseVersion}-${timestamp}-${shortCommit}` + const distTag = `dev-${baseVersion}` + validateDistTag(distTag) + const artifactDirectory = resolve(options.outputDirectory, version) + if (existsSync(artifactDirectory)) { + throw new Error(`output already exists: ${artifactDirectory}`) + } + return new BaselinePackPlan( + commit, + shortCommit, + timestamp, + baseVersion, + version, + distTag, + registry, + artifactDirectory, + ) + } + + pack(plan: BaselinePackPlan): ReleaseBundle { + const { artifactDirectory } = plan + if (existsSync(artifactDirectory)) { + throw new Error(`output already exists: ${artifactDirectory}`) + } + const worktree = DetachedWorktree.create(this.repositoryRoot, plan.commit, this.runner) + let createdArtifactDirectory = false + try { + const packageSet = WorkspacePackageSet.discover(worktree.path) + if (packageSet.baseVersion !== plan.baseVersion) { + throw new Error( + `workspace package version ${packageSet.baseVersion} does not match root version ` + + `${plan.baseVersion} at ${plan.commit}`, + ) + } + + console.log(`publish-npm-baseline: installing detached worktree ${plan.shortCommit}`) + this.runner.run('pnpm', ['install', '--frozen-lockfile'], worktree.path) + this.runner.run('pnpm', ['run', 'constraints'], worktree.path) + packageSet.stage(worktree.path, plan.version) + mkdirSync(artifactDirectory, { recursive: true }) + createdArtifactDirectory = true + + console.log( + `publish-npm-baseline: building ${packageSet.packages.length} packages as ${plan.version}`, + ) + this.runner.run('pnpm', ['run', 'build'], worktree.path) + this.runner.run('pnpm', ['run', 'publint'], worktree.path) + this.runner.run('pnpm', ['run', 'verify-built-package-invariants'], worktree.path) + this.runner.run('pnpm', [ + '--filter', './vendor/**', + '--filter', './packages/**', + '--filter', './apps/**', + '--recursive', + 'pack', + '--pack-destination', artifactDirectory, + ], worktree.path) + + const bundle = ReleaseBundle.create( + artifactDirectory, + packageSet.packages, + plan.commit, + plan.version, + plan.distTag, + plan.registry, + this.runner, + ) + new InstalledBundleSmoke(bundle, this.runner).run() + createdArtifactDirectory = false + console.log(`publish-npm-baseline: packed ${bundle.manifest.packages.length} packages`) + console.log(` version: ${bundle.manifest.version}`) + console.log(` dist-tag: ${bundle.manifest.distTag}`) + console.log(` manifest: ${resolve(bundle.directory, RELEASE_MANIFEST_NAME)}`) + console.log(' publish: ' + formatCopyableCommand('pnpm', [ + '--dir', + this.repositoryRoot, + 'exec', + 'tsx', + resolve(this.repositoryRoot, 'scripts/publish-npm-baseline.ts'), + 'publish', + '--manifest', + resolve(bundle.directory, RELEASE_MANIFEST_NAME), + '--yes', + ])) + return bundle + } finally { + worktree.dispose() + if (createdArtifactDirectory) { + rmSync(artifactDirectory, { recursive: true, force: true }) + } + } + } +} + +/** Publishes and verifies a release bundle against its recorded registry. */ +class RegistryPublication { + private readonly npmEnvironment = npmClientEnvironment() + private readonly npmWorkingDirectory = tmpdir() + + constructor( + private readonly bundle: ReleaseBundle, + private readonly runner: CommandRunner, + ) {} + + async publish(assumeYes: boolean): Promise<void> { + this.pingRegistry() + this.requireIdentity() + if (!assumeYes) await this.confirm() + + for (const pkg of this.bundle.manifest.packages) { + const existingIntegrity = this.remoteIntegrity(pkg.name) + if (existingIntegrity === undefined) { + this.runner.run('npm', [ + 'publish', + this.bundle.tarballPath(pkg), + `--registry=${this.bundle.manifest.registry}`, + `--tag=${this.bundle.manifest.distTag}`, + ], this.npmWorkingDirectory, this.npmEnvironment) + } else { + if (existingIntegrity !== pkg.integrity) { + throw new Error( + `${pkg.name}@${this.bundle.manifest.version} already exists with different integrity`, + ) + } + console.log( + `publish-npm-baseline: already published ${pkg.name}@${this.bundle.manifest.version}`, + ) + } + this.ensureDistTag(pkg.name, this.bundle.manifest.distTag) + } + this.ensureDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG) + this.verifyRemote() + this.verifyReleaseEntryDistTag() + } + + verify(): void { + this.pingRegistry() + this.verifyRemote() + this.verifyReleaseEntryDistTag() + } + + private verifyRemote(): void { + for (const pkg of this.bundle.manifest.packages) { + const integrity = this.remoteIntegrity(pkg.name) + if (integrity === undefined) { + throw new Error(`package is missing: ${pkg.name}@${this.bundle.manifest.version}`) + } + if (integrity !== pkg.integrity) { + throw new Error(`integrity mismatch: ${pkg.name}@${this.bundle.manifest.version}`) + } + const tagVersion = this.remoteDistTag(pkg.name, this.bundle.manifest.distTag) + if (tagVersion !== this.bundle.manifest.version) { + throw new Error( + `${pkg.name}@${this.bundle.manifest.distTag} points to ${tagVersion ?? '<missing>'}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + console.log(`publish-npm-baseline: verified ${pkg.name}@${this.bundle.manifest.version}`) + } + console.log( + `publish-npm-baseline: verified ${this.bundle.manifest.packages.length} packages and ` + + `dist-tag ${this.bundle.manifest.distTag}`, + ) + } + + private verifyReleaseEntryDistTag(): void { + const tagVersion = this.remoteDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG) + if (tagVersion !== this.bundle.manifest.version) { + throw new Error( + `${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} points to ${tagVersion ?? '<missing>'}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + console.log( + `publish-npm-baseline: verified ${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} at ` + + this.bundle.manifest.version, + ) + } + + private pingRegistry(): void { + const { registry } = this.bundle.manifest + this.runner.capture( + 'npm', ['ping', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment, + ) + } + + private requireIdentity(): void { + const { registry } = this.bundle.manifest + const identity = this.runner.capture( + 'npm', ['whoami', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment, + ) + console.log(`publish-npm-baseline: registry identity ${identity} at ${registry}`) + } + + private async confirm(): Promise<void> { + await confirmEnter( + `Publish ${this.bundle.manifest.packages.length} packages as ` + + `${this.bundle.manifest.version} to ${this.bundle.manifest.registry}? ` + + 'Press Enter to continue or type anything to cancel: ', + 'publish requires an interactive terminal or --yes', + 'publication cancelled', + ) + } + + private remoteIntegrity(name: string): string | undefined { + const { registry, version } = this.bundle.manifest + const result = this.runner.result( + 'npm', + ['view', `${name}@${version}`, 'dist.integrity', '--json', `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + if (result.status !== 0) { + if (/E404|NOT_FOUND|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) return undefined + throw commandFailure('npm', ['view', `${name}@${version}`], result) + } + const value: unknown = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout) + if (typeof value !== 'string' || !value.startsWith('sha512-')) { + throw new Error(`registry returned no integrity for ${name}@${version}`) + } + return value + } + + private remoteDistTag(name: string, distTag: string): string | undefined { + const { registry } = this.bundle.manifest + const raw = this.runner.capture( + 'npm', + ['dist-tag', 'ls', name, `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + return parseDistTagListing(raw, name).get(distTag) + } + + private ensureDistTag(name: string, distTag: string): void { + if (this.remoteDistTag(name, distTag) === this.bundle.manifest.version) return + const { registry, version } = this.bundle.manifest + this.runner.run( + 'npm', + ['dist-tag', 'add', `${name}@${version}`, distTag, `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + } +} + +interface InspectedTarball { + name: string + version: string + private: unknown + manifest: Record<string, unknown> + files: string[] +} + +function inspectTarball(path: string, runner: CommandRunner): InspectedTarball { + const manifest = JSON.parse( + runner.capture('tar', ['-xOf', path, 'package/package.json'], dirname(path)), + ) as unknown + if (!isRecord(manifest)) throw new Error(`${path} contains an invalid package.json`) + return { + name: expectString(manifest, 'name', path), + version: expectString(manifest, 'version', path), + private: manifest.private, + manifest, + files: runner.capture('tar', ['-tf', path], dirname(path)).split(/\r?\n/), + } +} + +function packedPackage(name: string, path: string, origin: PackageOrigin): PackedPackage { + const bytes = readFileSync(path) + return { + name, + tarball: basename(path), + sha256: createHash('sha256').update(bytes).digest('hex'), + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + origin, + } +} + +function parsePackedPackage(value: unknown, index: number): PackedPackage { + if (!isRecord(value)) throw new Error(`invalid release manifest package at index ${index}`) + const context = `release manifest package at index ${index}` + const name = expectString(value, 'name', context) + const origin = value.origin === undefined ? 'harness' : value.origin + if (origin !== 'harness' && origin !== 'vendor') { + throw new Error(`invalid package origin in release manifest: ${JSON.stringify(origin)}`) + } + if (origin === 'harness' && (!name.startsWith('@deepseek-ai/') || name === '@deepseek-ai/dsh-root')) { + throw new Error(`invalid package name in release manifest: ${name}`) + } + return { + name, + tarball: expectString(value, 'tarball', context), + sha256: expectString(value, 'sha256', context), + integrity: expectString(value, 'integrity', context), + origin, + } +} + +function containsWorkspaceProtocol(value: unknown): boolean { + if (typeof value === 'string') return value.startsWith('workspace:') + if (Array.isArray(value)) return value.some(containsWorkspaceProtocol) + return isRecord(value) && Object.values(value).some(containsWorkspaceProtocol) +} + +function stageInternalDependencies( + manifest: Record<string, unknown>, + internalNames: ReadonlySet<string>, + releaseVersion: string, + context: string, +): void { + for (const { dependencies, name } of internalDependencyEntries(manifest, internalNames, context)) { + dependencies[name] = releaseVersion + } +} + +function validateInternalDependencyPins( + manifest: Record<string, unknown>, + internalNames: ReadonlySet<string>, + releaseVersion: string, + context: string, +): void { + for (const { section, name, range } of internalDependencyEntries(manifest, internalNames, context)) { + if (range !== releaseVersion) { + throw new Error( + `${context} has internal ${section} ${name}@${String(range)}; ` + + `expected exact version ${releaseVersion}`, + ) + } + } +} + +function* internalDependencyEntries( + manifest: Record<string, unknown>, + internalNames: ReadonlySet<string>, + context: string, +): Generator<{ + section: typeof DEPENDENCY_SECTIONS[number] + dependencies: Record<string, unknown> + name: string + range: unknown +}> { + for (const section of DEPENDENCY_SECTIONS) { + const dependencies = manifest[section] + if (dependencies === undefined) continue + if (!isRecord(dependencies)) throw new Error(`${context} ${section} must be an object`) + for (const [name, range] of Object.entries(dependencies)) { + if (!internalNames.has(name)) continue + yield { section, dependencies, name, range } + } + } +} + +function readObject(path: string): Record<string, unknown> { + return parseObject(readFileSync(path, 'utf8'), path) +} + +function parseObject(source: string, context: string): Record<string, unknown> { + const value: unknown = JSON.parse(source) + if (!isRecord(value)) throw new Error(`${context} must contain a JSON object`) + return value +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function expectString(value: Record<string, unknown>, key: string, context: string): string { + const result = value[key] + if (typeof result !== 'string' || result === '') { + throw new Error(`${context} must contain a non-empty ${key}`) + } + return result +} + +function normalizeRegistry(value: string): string { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`registry must use HTTP or HTTPS: ${value}`) + } + return value.replace(/\/+$/, '') +} + +function npmClientEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env } + delete environment.npm_config_user_agent + delete environment.NPM_CONFIG_USER_AGENT + return environment +} + +function installedArtifactEnvironment(consumerRoot: string): NodeJS.ProcessEnv { + const environment = npmClientEnvironment() + delete environment.NODE_OPTIONS + delete environment.NODE_PATH + environment.DSH_HOME = resolve(consumerRoot, '.dsh') + environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents') + environment.DSH_TELEMETRY_DISABLED = '1' + environment.DEEPSEEK_API_KEY = 'keyless-installed-web-no-call' + environment.LANG = 'en_US.UTF-8' + environment.LC_ALL = 'en_US.UTF-8' + environment.LC_CTYPE = 'en_US.UTF-8' + environment.TERM = 'xterm-256color' + environment.COLUMNS = '100' + environment.LINES = '30' + delete environment.COLORTERM + return environment +} + +function assertPathWithin(root: string, path: string, label: string): void { + const rootPath = realpathSync.native(root) + const candidate = realpathSync.native(path) + const fromRoot = relative(rootPath, candidate) + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error(`${label} resolved outside the isolated consumer: ${candidate}`) + } +} + +function validateDistTag(value: string): void { + if (value === '' || /\s/.test(value)) throw new Error(`invalid dist-tag: ${JSON.stringify(value)}`) +} + +function validateBaseVersion(value: string, context: string): void { + if (!/^\d+\.\d+\.\d+$/.test(value)) { + throw new Error(`${context} must have a stable X.Y.Z version, got ${value}`) + } +} + +function parseDistTagListing(raw: string, name: string): Map<string, string> { + const tags = new Map<string, string>() + for (const line of raw.split(/\r?\n/)) { + if (line === '') continue + const separator = line.indexOf(': ') + if (separator <= 0 || separator + 2 === line.length) { + throw new Error(`registry returned an invalid dist-tag for ${name}: ${line}`) + } + const tag = line.slice(0, separator) + if (tags.has(tag)) throw new Error(`registry returned duplicate dist-tag ${tag} for ${name}`) + tags.set(tag, line.slice(separator + 2)) + } + return tags +} + +async function confirmEnter( + prompt: string, + nonInteractiveError: string, + cancellationError: string, +): Promise<void> { + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(nonInteractiveError) + const readline = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await readline.question(prompt) + if (answer !== '') throw new Error(cancellationError) + } finally { + readline.close() + } +} + +function formatUtcTimestamp(value: Date): string { + if (!Number.isFinite(value.getTime())) throw new Error('pack timestamp must be a valid date') + return value.toISOString().replaceAll(/[-:TZ.]/g, '').slice(0, 14) +} + +function commandFailure(command: string, args: string[], result: CommandResult): Error { + const detail = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n') + return new Error( + `${formatCommand(command, args)} exited with status ${result.status}${detail === '' ? '' : `\n${detail}`}`, + ) +} + +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(value => JSON.stringify(value)).join(' ') +} + +function formatCopyableCommand(command: string, args: string[]): string { + return [command, ...args].map(quoteShellArgument).join(' ') +} + +function quoteShellArgument(value: string): string { + if (/^[\w./:@=+-]+$/.test(value)) return value + const singleQuote = String.fromCodePoint(39) + const escapedSingleQuote = `${singleQuote}"${singleQuote}"${singleQuote}` + return `${singleQuote}${value.replaceAll(singleQuote, escapedSingleQuote)}${singleQuote}` +} + +function printUsage(): void { + console.log(`Usage: + pnpm exec tsx scripts/publish-npm-baseline.ts pack [options] + pnpm exec tsx scripts/publish-npm-baseline.ts release [options] [--yes] + pnpm exec tsx scripts/publish-npm-baseline.ts publish --manifest <path> [--yes] + pnpm exec tsx scripts/publish-npm-baseline.ts verify --manifest <path> + +Pack/release options: + --ref <git-ref> Git commit to stage (default: HEAD) + --registry <url> npm registry (default: ${DEFAULT_REGISTRY}) + --output-dir <path> Artifact root (default: ${DEFAULT_OUTPUT_DIRECTORY}) + --yes pack/release without waiting for Enter`) +} + +async function main(): Promise<void> { + const command = process.argv[2] + if (command === undefined || command === 'help' || command === '--help' || command === '-h') { + printUsage() + return + } + if (process.argv.slice(3).some(value => value === '--help' || value === '-h')) { + printUsage() + return + } + const runner = new CommandRunner() + const repositoryRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], process.cwd()) + + if (command === 'pack' || command === 'release') { + const { values } = parseArgs({ + args: process.argv.slice(3), + options: { + ref: { type: 'string', default: 'HEAD' }, + registry: { type: 'string', default: DEFAULT_REGISTRY }, + 'output-dir': { type: 'string', default: resolve(repositoryRoot, DEFAULT_OUTPUT_DIRECTORY) }, + yes: { type: 'boolean', default: false }, + }, + strict: true, + }) + const packager = new BaselinePackager(repositoryRoot, runner) + const plan = packager.plan({ + ref: values.ref, + registry: values.registry, + outputDirectory: resolve(values['output-dir']), + }) + await plan.confirm(values.yes) + const bundle = packager.pack(plan) + if (command === 'release') { + await new RegistryPublication(bundle, runner).publish(values.yes) + } + return + } + + if (command === 'publish' || command === 'verify') { + const { values } = parseArgs({ + args: process.argv.slice(3), + options: { + manifest: { type: 'string' }, + yes: { type: 'boolean', default: false }, + }, + strict: true, + }) + if (values.manifest === undefined) throw new Error(`${command} requires --manifest`) + if (command === 'verify' && values.yes) throw new Error('verify does not accept --yes') + const bundle = ReleaseBundle.load(values.manifest, runner) + const publication = new RegistryPublication(bundle, runner) + if (command === 'publish') await publication.publish(values.yes) + else publication.verify() + return + } + + throw new Error(`unknown command: ${command}`) +} + +try { + await main() +} catch (error: unknown) { + console.error(`publish-npm-baseline: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..238c513433 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -597,6 +597,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'apps/cli/tests/built-bin.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', + 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 3aef54ef2d..0aa189024d 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,31 +4,31 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 本仓库的命名架构概念,正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Configured runtime\n\nRaw `dsh` requires a patch-list configuration applied over the shipped base:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nThe [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Configured runtime\n\nRaw `dsh` requires a patch-list configuration applied over the shipped base:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nThe [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### 自定义运行时\n\n原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nbase、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### 自定义运行时\n\n原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nbase、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", @@ -44,7 +44,7 @@ }, { "role": "assistant", - "content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index 949962b91c..e3b14f169e 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -1,4 +1,4 @@ -/** Unit tests for the prompt-v4 renderer and three-section response parser. */ +/** Unit tests for the prompt-v7 content and unchanged three-section protocol. */ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' @@ -15,6 +15,20 @@ const root = resolve(import.meta.dirname, '..') const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8') const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |' +const retainedExamples = [ + ['### Colloquial verb → Professional verb', 'The repo pins pnpm@11.7.0 in package.json', '该仓库在 package.json 中固定使用 pnpm@11.7.0'], + ['### Run-on sentence → Natural phrasing with pause', 'Read docs/architecture.md before changing anything under packages/.', '在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。'], + ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'], + ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'], + ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'], + ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'], + ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'], + ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'], + ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'], + ['### Code block comments — NEVER translate', '# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)', 'keep exactly as-is, byte-for-byte'], + ['### Language switcher — flip direction', 'English | [中文](README.zh.md)', '[English](README.md) | 中文'], +] + describe('translation prompt rendering', () => { it('renders both directions with every placeholder resolved', () => { const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology }) @@ -22,15 +36,31 @@ describe('translation prompt rendering', () => { expect(en).toContain(terminology) expect(en).not.toContain('{{') expect(en).toContain('plain source stays plain (必须)') - expect(en).toContain('When the target language is English, use the "English" column without a Chinese gloss') - expect(en).toContain('for a Chinese target, use an established Chinese rendering') - expect(en).toContain('for an English target, use the established English technical term') - expect(en).toContain('does an English target use established English terminology') + expect(en).toContain('For an English target, use the established English technical term') + expect(en).toContain('does a Chinese target use an established Chinese rendering') + expect(en).toContain('does an English target use the established English technical term') expect(en).toContain('The parser removes exactly one framing escape') const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology }) expect(zh).toContain('from Chinese to English') }) + it('retains every v4 embedded example', () => { + for (const example of retainedExamples) { + for (const fragment of example) expect(document).toContain(fragment) + } + }) + + it('states the selected v7 safeguards', () => { + const rendered = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology }) + expect(rendered).toContain('## Priority') + expect(rendered).toContain('### Faithfulness') + expect(rendered).toContain('do not invent a filename or switcher') + expect(rendered).toContain('Markdown emphasis markers do not create a word boundary') + expect(rendered).toContain('Never invent responsibility merely to avoid a passive construction') + expect(rendered).toContain('Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety') + expect(rendered).toContain('Return exactly three raw XML sections') + }) + it('rejects a template with unknown or missing placeholders', () => { const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}') expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3d1fcc8f9d..73bb8844d0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -26,6 +26,21 @@ "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextForm", + "source": "packages/llm/llm/src/message.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextSnapshotSection", + "source": "packages/llm/llm/src/message.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextFormed", + "source": "packages/llm/llm/src/message.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", @@ -98,33 +113,8 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "SendTarget", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxPlacement", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxItem", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxAction", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxActionResult", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendOptions", - "source": "packages/core/agent/src/types.ts" + "symbol": "InboxTarget", + "source": "packages/core/agent/src/inbox.ts" }, { "doc": "docs/core-data-structures/core.md", @@ -134,7 +124,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "AgentCancelCause", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", @@ -143,7 +133,12 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "PromptDecision", + "symbol": "PreStepContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "PreStepDecision", "source": "packages/core/agent/src/types.ts" }, { @@ -151,11 +146,6 @@ "symbol": "RequestErrorAction", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "RequestError", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", @@ -370,7 +360,7 @@ }, { "doc": "docs/core-data-structures/session.md", - "symbol": "TurnTriggerMap", + "symbol": "TurnEndCancelCause", "source": "packages/core/session/src/types.ts" }, { @@ -424,6 +414,32 @@ "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "RestoredSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "PrepareSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionPreparationOptions", + "source": "packages/core/session/src/preparation.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionPreparation", + "source": "packages/core/session/src/preparation.ts", + "projection": "public-api" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionInspection", + "source": "packages/session-persistence/session-persistence/src/index.ts" + }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", @@ -889,6 +905,11 @@ "symbol": "SandboxPolicyRequest", "source": "packages/sandbox/sandbox-policy/src/index.ts" }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "RunnerFailureRule", + "source": "packages/sandbox/sandbox/src/index.ts" + }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4104ff8fdc..041972cb9f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,7 +42,9 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = { */ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, + 'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, + 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index bde4c2eb00..5d67af1e51 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -22,7 +22,7 @@ Do not assume a path or branch name. DSH is usually installed from source with a 1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. 2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. -3. Test assembled TUI behavior interactively in a dedicated tmux session. Test other interactive UI behavior in a browser; unit tests and snapshots alone are insufficient. +3. Test assembled interactive behavior in the Web UI; unit tests and snapshots alone are insufficient. 4. Record the task tip and confirm the task worktree is clean before integration. ## Integrate under the lock diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md index 944f4b083c..7ec1618db3 100644 --- a/skills/dsh-upstream-customization/SKILL.md +++ b/skills/dsh-upstream-customization/SKILL.md @@ -23,5 +23,5 @@ Classification and a recommendation are not publishing approval. Obtain explicit 3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. 4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. 5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. -6. For a TUI feature, preferably attach a screenshot from the assembled application after removing credentials and personal data. +6. For a Web UI feature, attach a screenshot or GIF from the assembled application after removing credentials and personal data. 7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL. diff --git a/tsconfig.base.json b/tsconfig.base.json index 43c643d9c2..9ba9ba5d84 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -53,6 +53,9 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], + "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"], + "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"], + "@deepseek-ai/dsh-bash-env": ["./packages/bash/bash-env/src/index.ts"], "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"], "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..4fcf71b680 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -18,6 +18,8 @@ "apps/web/tests/plan-review.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", + "apps/web/tests/chat-scroll-fixture.ts", + "apps/web/tests/trajectory-virtualization.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", @@ -35,6 +37,9 @@ "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", + "apps/web/tests/math-rendering.e2e.ts", + "apps/web/tests/markdown-cjk-strong.e2e.ts", + "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", @@ -48,7 +53,9 @@ "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", + "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", + "apps/web/tests/pwsh-terminal.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", @@ -148,6 +155,9 @@ { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/bash-env" }, + { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, diff --git a/vendor/README.md b/vendor/README.md index 6788409cb3..c59a86ccca 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,7 +31,7 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. diff --git a/vendor/loader/package.json b/vendor/loader/package.json index e24e0657a6..509c558ed3 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -29,7 +29,7 @@ "license": "MIT", "peerDependencies": { "cordis": "^4.0.0-rc.7", - "node-addon-require-builtin": "^0.1.3" + "node-addon-require-builtin": "^0.1.4" }, "peerDependenciesMeta": { "node-addon-require-builtin": { diff --git a/vitest.config.ts b/vitest.config.ts index ddf7741716..cd37feb300 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,17 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' +import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' +// Prints exact `path:line:col` records for every uncovered statement, branch +// path, and function when a file misses the per-file 100% gate — the built-in +// threshold ERRORs name only the file. Absolute path because istanbul-reports +// require()s custom reporters (which is also why the reporter is CJS). +const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-uncovered-locations.cjs', import.meta.url)) + // Resolution facade shared by every plugin instance below: tsconfig.base.json // has no include, which vite-tsconfig-paths treats as match-all, so its paths // map applies to every test file. paths must win over package exports so built @@ -11,7 +20,14 @@ const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ proj const windowsUnsupportedPackages = process.platform === 'win32' ? [ - 'packages/bash/*', + // Bash-requiring suites (a real POSIX shell is unavailable on Windows). + // The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay + // INCLUDED: PowerShell ships with Windows, so they run natively here. + // Replacing the old 'packages/bash/*' glob with this explicit list also + // newly INCLUDES packages/bash/bash (the pure seam package) on Windows. + 'packages/bash/bash-local', + 'packages/bash/bash-sandbox', + 'packages/bash/tool-bash', 'packages/hooks/*', 'packages/subprocess/*', 'packages/pty/pty-local', @@ -31,6 +47,17 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites +// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file +// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts +// green while CI runners ship pwsh and still enforce the full bar. The probe +// runs the suites' own resolution (the dependency-free resolve.ts module), +// so the exemption is active exactly when the suites skip — a mismatched +// narrower probe could exempt the file on hosts whose suites actually run. +const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + ? [] + : ['packages/bash/pwsh-local/src/index.ts'] + const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', 'apps/*/tests/**/*.spec.ts', @@ -194,6 +221,7 @@ export default defineConfig({ 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. @@ -206,7 +234,9 @@ export default defineConfig({ functions: 100, lines: 100, }, - reporter: process.env.CI ? ['text'] : ['text', 'html'], + reporter: process.env.CI + ? ['text', uncoveredLocationsReporter] + : ['text', 'html', uncoveredLocationsReporter], }, }, })